Merge pull request 'ci: Gitea Actions CI/CD 파이프라인 + 기능 업데이트' (#2) from develop into main
This commit is contained in:
커밋
aeb57e9bb8
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
|
||||
85
.claude/settings.json
Normal file
85
.claude/settings.json
Normal file
@ -0,0 +1,85 @@
|
||||
{
|
||||
"$schema": "https://json.schemastore.org/claude-code-settings.json",
|
||||
"permissions": {
|
||||
"allow": [
|
||||
"Bash(npm run *)",
|
||||
"Bash(npm -w *)",
|
||||
"Bash(npm install *)",
|
||||
"Bash(npm test *)",
|
||||
"Bash(npx *)",
|
||||
"Bash(node *)",
|
||||
"Bash(git status)",
|
||||
"Bash(git diff *)",
|
||||
"Bash(git log *)",
|
||||
"Bash(git branch *)",
|
||||
"Bash(git checkout *)",
|
||||
"Bash(git add *)",
|
||||
"Bash(git commit *)",
|
||||
"Bash(git pull *)",
|
||||
"Bash(git fetch *)",
|
||||
"Bash(git merge *)",
|
||||
"Bash(git stash *)",
|
||||
"Bash(git remote *)",
|
||||
"Bash(git config *)",
|
||||
"Bash(git rev-parse *)",
|
||||
"Bash(git show *)",
|
||||
"Bash(git tag *)",
|
||||
"Bash(curl -s *)",
|
||||
"Bash(fnm *)"
|
||||
],
|
||||
"deny": [
|
||||
"Bash(git push --force*)",
|
||||
"Bash(git push -f *)",
|
||||
"Bash(git push origin --force*)",
|
||||
"Bash(git reset --hard*)",
|
||||
"Bash(git clean -fd*)",
|
||||
"Bash(git checkout -- .)",
|
||||
"Bash(git restore .)",
|
||||
"Bash(rm -rf /)",
|
||||
"Bash(rm -rf ~)",
|
||||
"Bash(rm -rf .git*)",
|
||||
"Bash(rm -rf /*)",
|
||||
"Bash(rm -rf node_modules)",
|
||||
"Read(./**/.env)",
|
||||
"Read(./**/.env.*)",
|
||||
"Read(./**/secrets/**)"
|
||||
]
|
||||
},
|
||||
"hooks": {
|
||||
"SessionStart": [
|
||||
{
|
||||
"matcher": "compact",
|
||||
"hooks": [
|
||||
{
|
||||
"type": "command",
|
||||
"command": "bash .claude/scripts/on-post-compact.sh",
|
||||
"timeout": 10
|
||||
}
|
||||
]
|
||||
}
|
||||
],
|
||||
"PreCompact": [
|
||||
{
|
||||
"hooks": [
|
||||
{
|
||||
"type": "command",
|
||||
"command": "bash .claude/scripts/on-pre-compact.sh",
|
||||
"timeout": 30
|
||||
}
|
||||
]
|
||||
}
|
||||
],
|
||||
"PostToolUse": [
|
||||
{
|
||||
"matcher": "Bash",
|
||||
"hooks": [
|
||||
{
|
||||
"type": "command",
|
||||
"command": "bash .claude/scripts/on-commit.sh",
|
||||
"timeout": 15
|
||||
}
|
||||
]
|
||||
}
|
||||
]
|
||||
}
|
||||
}
|
||||
65
.claude/skills/create-mr/SKILL.md
Normal file
65
.claude/skills/create-mr/SKILL.md
Normal file
@ -0,0 +1,65 @@
|
||||
---
|
||||
name: create-mr
|
||||
description: 현재 브랜치에서 Gitea MR(Merge Request)을 생성합니다
|
||||
allowed-tools: "Bash, Read, Grep"
|
||||
argument-hint: "[target-branch: develop|main] (기본: develop)"
|
||||
---
|
||||
|
||||
현재 브랜치의 변경 사항을 기반으로 Gitea에 MR을 생성합니다.
|
||||
타겟 브랜치: $ARGUMENTS (기본: develop)
|
||||
|
||||
## 수행 단계
|
||||
|
||||
### 1. 사전 검증
|
||||
- 현재 브랜치가 main/develop이 아닌지 확인
|
||||
- 커밋되지 않은 변경 사항 확인 (있으면 경고)
|
||||
- 리모트에 현재 브랜치가 push되어 있는지 확인 (안 되어 있으면 push)
|
||||
|
||||
### 2. 변경 내역 분석
|
||||
```bash
|
||||
git log develop..HEAD --oneline
|
||||
git diff develop..HEAD --stat
|
||||
```
|
||||
- 커밋 목록과 변경된 파일 목록 수집
|
||||
- 주요 변경 사항 요약 작성
|
||||
|
||||
### 3. MR 정보 구성
|
||||
- **제목**: 브랜치의 첫 커밋 메시지 또는 브랜치명에서 추출
|
||||
- `feature/ISSUE-42-user-login` → `feat: ISSUE-42 user-login`
|
||||
- **본문**:
|
||||
```markdown
|
||||
## 변경 사항
|
||||
- (커밋 기반 자동 생성)
|
||||
|
||||
## 관련 이슈
|
||||
- closes #이슈번호 (브랜치명에서 추출)
|
||||
|
||||
## 테스트
|
||||
- [ ] 빌드 성공 확인
|
||||
- [ ] 기존 테스트 통과
|
||||
```
|
||||
|
||||
### 4. Gitea API로 MR 생성
|
||||
```bash
|
||||
# Gitea remote URL에서 owner/repo 추출
|
||||
REMOTE_URL=$(git remote get-url origin)
|
||||
|
||||
# Gitea API 호출
|
||||
curl -X POST "GITEA_URL/api/v1/repos/{owner}/{repo}/pulls" \
|
||||
-H "Authorization: token ${GITEA_TOKEN}" \
|
||||
-H "Content-Type: application/json" \
|
||||
-d '{
|
||||
"title": "MR 제목",
|
||||
"body": "MR 본문",
|
||||
"head": "현재브랜치",
|
||||
"base": "타겟브랜치"
|
||||
}'
|
||||
```
|
||||
|
||||
### 5. 결과 출력
|
||||
- MR URL 출력
|
||||
- 리뷰어 지정 안내
|
||||
- 다음 단계: 리뷰 대기 → 승인 → 머지
|
||||
|
||||
## 필요 환경변수
|
||||
- `GITEA_TOKEN`: Gitea API 접근 토큰 (없으면 안내)
|
||||
49
.claude/skills/fix-issue/SKILL.md
Normal file
49
.claude/skills/fix-issue/SKILL.md
Normal file
@ -0,0 +1,49 @@
|
||||
---
|
||||
name: fix-issue
|
||||
description: Gitea 이슈를 분석하고 수정 브랜치를 생성합니다
|
||||
allowed-tools: "Bash, Read, Write, Edit, Glob, Grep"
|
||||
argument-hint: "<issue-number>"
|
||||
---
|
||||
|
||||
Gitea 이슈 #$ARGUMENTS 를 분석하고 수정 작업을 시작합니다.
|
||||
|
||||
## 수행 단계
|
||||
|
||||
### 1. 이슈 조회
|
||||
```bash
|
||||
curl -s "GITEA_URL/api/v1/repos/{owner}/{repo}/issues/$ARGUMENTS" \
|
||||
-H "Authorization: token ${GITEA_TOKEN}"
|
||||
```
|
||||
- 이슈 제목, 본문, 라벨, 담당자 정보 확인
|
||||
- 이슈 내용을 사용자에게 요약하여 보여줌
|
||||
|
||||
### 2. 브랜치 생성
|
||||
이슈 라벨에 따라 브랜치 타입 결정:
|
||||
- `bug` 라벨 → `bugfix/ISSUE-번호-설명`
|
||||
- 그 외 → `feature/ISSUE-번호-설명`
|
||||
- 긴급 → `hotfix/ISSUE-번호-설명`
|
||||
|
||||
```bash
|
||||
git checkout develop
|
||||
git pull origin develop
|
||||
git checkout -b {type}/ISSUE-{number}-{slug}
|
||||
```
|
||||
|
||||
### 3. 이슈 분석
|
||||
이슈 내용을 바탕으로:
|
||||
- 관련 파일 탐색 (Grep, Glob 활용)
|
||||
- 영향 범위 파악
|
||||
- 수정 방향 제안
|
||||
|
||||
### 4. 수정 계획 제시
|
||||
사용자에게 수정 계획을 보여주고 승인을 받은 후 작업 진행:
|
||||
- 수정할 파일 목록
|
||||
- 변경 내용 요약
|
||||
- 예상 영향
|
||||
|
||||
### 5. 작업 완료 후
|
||||
- 변경 사항 요약
|
||||
- `/create-mr` 실행 안내
|
||||
|
||||
## 필요 환경변수
|
||||
- `GITEA_TOKEN`: Gitea API 접근 토큰
|
||||
246
.claude/skills/init-project/SKILL.md
Normal file
246
.claude/skills/init-project/SKILL.md
Normal file
@ -0,0 +1,246 @@
|
||||
---
|
||||
name: init-project
|
||||
description: 팀 표준 워크플로우로 프로젝트를 초기화합니다
|
||||
allowed-tools: "Bash, Read, Write, Edit, Glob, Grep"
|
||||
argument-hint: "[project-type: java-maven|java-gradle|react-ts|auto]"
|
||||
---
|
||||
|
||||
팀 표준 워크플로우에 따라 프로젝트를 초기화합니다.
|
||||
프로젝트 타입: $ARGUMENTS (기본: auto — 자동 감지)
|
||||
|
||||
## 프로젝트 타입 자동 감지
|
||||
|
||||
$ARGUMENTS가 "auto"이거나 비어있으면 다음 순서로 감지:
|
||||
1. `pom.xml` 존재 → **java-maven**
|
||||
2. `build.gradle` 또는 `build.gradle.kts` 존재 → **java-gradle**
|
||||
3. `package.json` + `tsconfig.json` 존재 → **react-ts**
|
||||
4. 감지 실패 → 사용자에게 타입 선택 요청
|
||||
|
||||
## 수행 단계
|
||||
|
||||
### 1. 프로젝트 분석
|
||||
- 빌드 파일, 설정 파일, 디렉토리 구조 파악
|
||||
- 사용 중인 프레임워크, 라이브러리 감지
|
||||
- 기존 `.claude/` 디렉토리 존재 여부 확인
|
||||
- eslint, prettier, checkstyle, spotless 등 lint 도구 설치 여부 확인
|
||||
|
||||
### 2. CLAUDE.md 생성
|
||||
프로젝트 루트에 CLAUDE.md를 생성하고 다음 내용 포함:
|
||||
- 프로젝트 개요 (이름, 타입, 주요 기술 스택)
|
||||
- 빌드/실행 명령어 (감지된 빌드 도구 기반)
|
||||
- 테스트 실행 명령어
|
||||
- lint 실행 명령어 (감지된 도구 기반)
|
||||
- 프로젝트 디렉토리 구조 요약
|
||||
- 팀 컨벤션 참조 (`.claude/rules/` 안내)
|
||||
|
||||
### Gitea 파일 다운로드 URL 패턴
|
||||
⚠️ Gitea raw 파일은 반드시 **web raw URL**을 사용해야 합니다 (`/api/v1/` 경로 사용 불가):
|
||||
```bash
|
||||
GITEA_URL="${GITEA_URL:-https://gitea.gc-si.dev}"
|
||||
# common 파일: ${GITEA_URL}/gc/template-common/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"
|
||||
```
|
||||
|
||||
### 3. .claude/ 디렉토리 구성
|
||||
이미 팀 표준 파일이 존재하면 건너뜀. 없는 경우 위의 URL 패턴으로 Gitea에서 다운로드:
|
||||
- `.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)
|
||||
|
||||
### 4. Hook 스크립트 생성
|
||||
`.claude/scripts/` 디렉토리를 생성하고 다음 스크립트 파일 생성 (chmod +x):
|
||||
|
||||
- `.claude/scripts/on-pre-compact.sh`:
|
||||
|
||||
```bash
|
||||
#!/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
|
||||
```
|
||||
|
||||
- `.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
|
||||
```
|
||||
|
||||
- `.claude/scripts/on-commit.sh`:
|
||||
|
||||
```bash
|
||||
#!/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
|
||||
```
|
||||
|
||||
`.claude/settings.json`에 hooks 섹션이 없으면 추가 (기존 settings.json의 내용에 병합):
|
||||
|
||||
```json
|
||||
{
|
||||
"hooks": {
|
||||
"SessionStart": [
|
||||
{
|
||||
"matcher": "compact",
|
||||
"hooks": [
|
||||
{
|
||||
"type": "command",
|
||||
"command": "bash .claude/scripts/on-post-compact.sh",
|
||||
"timeout": 10
|
||||
}
|
||||
]
|
||||
}
|
||||
],
|
||||
"PreCompact": [
|
||||
{
|
||||
"hooks": [
|
||||
{
|
||||
"type": "command",
|
||||
"command": "bash .claude/scripts/on-pre-compact.sh",
|
||||
"timeout": 30
|
||||
}
|
||||
]
|
||||
}
|
||||
],
|
||||
"PostToolUse": [
|
||||
{
|
||||
"matcher": "Bash",
|
||||
"hooks": [
|
||||
{
|
||||
"type": "command",
|
||||
"command": "bash .claude/scripts/on-commit.sh",
|
||||
"timeout": 15
|
||||
}
|
||||
]
|
||||
}
|
||||
]
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
### 5. Git Hooks 설정
|
||||
```bash
|
||||
git config core.hooksPath .githooks
|
||||
```
|
||||
`.githooks/` 디렉토리에 실행 권한 부여:
|
||||
```bash
|
||||
chmod +x .githooks/*
|
||||
```
|
||||
|
||||
### 6. 프로젝트 타입별 추가 설정
|
||||
|
||||
#### java-maven
|
||||
- `.sdkmanrc` 생성 (java=17.0.18-amzn 또는 프로젝트에 맞는 버전)
|
||||
- `.mvn/settings.xml` Nexus 미러 설정 확인
|
||||
- `mvn compile` 빌드 성공 확인
|
||||
|
||||
#### java-gradle
|
||||
- `.sdkmanrc` 생성
|
||||
- `gradle.properties.example` Nexus 설정 확인
|
||||
- `./gradlew compileJava` 빌드 성공 확인
|
||||
|
||||
#### react-ts
|
||||
- `.node-version` 생성 (프로젝트에 맞는 Node 버전)
|
||||
- `.npmrc` Nexus 레지스트리 설정 확인
|
||||
- `npm install && npm run build` 성공 확인
|
||||
|
||||
### 7. .gitignore 확인
|
||||
다음 항목이 .gitignore에 포함되어 있는지 확인하고, 없으면 추가:
|
||||
```
|
||||
.claude/settings.local.json
|
||||
.claude/CLAUDE.local.md
|
||||
.env
|
||||
.env.*
|
||||
*.local
|
||||
```
|
||||
|
||||
### 8. Git exclude 설정
|
||||
`.git/info/exclude` 파일을 읽고, 기존 내용을 보존하면서 하단에 추가:
|
||||
|
||||
```gitignore
|
||||
|
||||
# Claude Code 워크플로우 (로컬 전용)
|
||||
docs/CHANGELOG.md
|
||||
*.tmp
|
||||
```
|
||||
|
||||
### 9. Memory 초기화
|
||||
프로젝트 memory 디렉토리의 위치를 확인하고 (보통 `~/.claude/projects/<project-hash>/memory/`) 다음 파일들을 생성:
|
||||
|
||||
- `memory/MEMORY.md` — 프로젝트 분석 결과 기반 핵심 요약 (200줄 이내)
|
||||
- 현재 상태, 프로젝트 개요, 기술 스택, 주요 패키지 구조, 상세 참조 링크
|
||||
- `memory/project-snapshot.md` — 디렉토리 구조, 패키지 구성, 주요 의존성, API 엔드포인트
|
||||
- `memory/project-history.md` — "초기 팀 워크플로우 구성" 항목으로 시작
|
||||
- `memory/api-types.md` — 주요 인터페이스/DTO/Entity 타입 요약
|
||||
- `memory/decisions.md` — 빈 템플릿 (# 의사결정 기록)
|
||||
- `memory/debugging.md` — 빈 템플릿 (# 디버깅 경험 & 패턴)
|
||||
|
||||
### 10. Lint 도구 확인
|
||||
- TypeScript: eslint, prettier 설치 여부 확인. 미설치 시 사용자에게 설치 제안
|
||||
- Java: checkstyle, spotless 등 설정 확인
|
||||
- CLAUDE.md에 lint 실행 명령어가 이미 기록되었는지 확인
|
||||
|
||||
### 11. workflow-version.json 생성
|
||||
Gitea API로 최신 팀 워크플로우 버전을 조회:
|
||||
```bash
|
||||
curl -sf --max-time 5 "https://gitea.gc-si.dev/gc/template-common/raw/branch/develop/workflow-version.json"
|
||||
```
|
||||
조회 성공 시 해당 `version` 값 사용, 실패 시 "1.0.0" 기본값 사용.
|
||||
|
||||
`.claude/workflow-version.json` 파일 생성:
|
||||
```json
|
||||
{
|
||||
"applied_global_version": "<조회된 버전>",
|
||||
"applied_date": "<현재날짜>",
|
||||
"project_type": "<감지된타입>",
|
||||
"gitea_url": "https://gitea.gc-si.dev"
|
||||
}
|
||||
```
|
||||
|
||||
### 12. 검증 및 요약
|
||||
- 생성/수정된 파일 목록 출력
|
||||
- `git config core.hooksPath` 확인
|
||||
- 빌드 명령 실행 가능 확인
|
||||
- Hook 스크립트 실행 권한 확인
|
||||
- 다음 단계 안내:
|
||||
- 개발 시작, 첫 커밋 방법
|
||||
- 범용 스킬: `/api-registry`, `/changelog`, `/swagger-spec`
|
||||
98
.claude/skills/sync-team-workflow/SKILL.md
Normal file
98
.claude/skills/sync-team-workflow/SKILL.md
Normal file
@ -0,0 +1,98 @@
|
||||
---
|
||||
name: sync-team-workflow
|
||||
description: 팀 글로벌 워크플로우를 현재 프로젝트에 동기화합니다
|
||||
allowed-tools: "Bash, Read, Write, Edit, Glob, Grep"
|
||||
---
|
||||
|
||||
팀 글로벌 워크플로우의 최신 버전을 현재 프로젝트에 적용합니다.
|
||||
|
||||
## 수행 절차
|
||||
|
||||
### 1. 글로벌 버전 조회
|
||||
Gitea API로 template-common 리포의 workflow-version.json 조회:
|
||||
```bash
|
||||
GITEA_URL=$(python3 -c "import json; print(json.load(open('.claude/workflow-version.json')).get('gitea_url', 'https://gitea.gc-si.dev'))" 2>/dev/null || echo "https://gitea.gc-si.dev")
|
||||
|
||||
curl -sf "${GITEA_URL}/gc/template-common/raw/branch/develop/workflow-version.json"
|
||||
```
|
||||
|
||||
### 2. 버전 비교
|
||||
로컬 `.claude/workflow-version.json`의 `applied_global_version` 필드와 비교:
|
||||
- 버전 일치 → "최신 버전입니다" 안내 후 종료
|
||||
- 버전 불일치 → 미적용 변경 항목 추출하여 표시
|
||||
|
||||
### 3. 프로젝트 타입 감지
|
||||
자동 감지 순서:
|
||||
1. `.claude/workflow-version.json`의 `project_type` 필드 확인
|
||||
2. 없으면: `pom.xml` → java-maven, `build.gradle` → java-gradle, `package.json` → react-ts
|
||||
|
||||
### Gitea 파일 다운로드 URL 패턴
|
||||
⚠️ Gitea raw 파일은 반드시 **web raw URL**을 사용해야 합니다 (`/api/v1/` 경로 사용 불가):
|
||||
```bash
|
||||
GITEA_URL="${GITEA_URL:-https://gitea.gc-si.dev}"
|
||||
# common 파일: ${GITEA_URL}/gc/template-common/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"
|
||||
```
|
||||
|
||||
### 4. 파일 다운로드 및 적용
|
||||
위의 URL 패턴으로 해당 타입 + common 템플릿 파일 다운로드:
|
||||
|
||||
#### 4-1. 규칙 파일 (덮어쓰기)
|
||||
팀 규칙은 로컬 수정 불가 — 항상 글로벌 최신으로 교체:
|
||||
```
|
||||
.claude/rules/team-policy.md
|
||||
.claude/rules/git-workflow.md
|
||||
.claude/rules/code-style.md (타입별)
|
||||
.claude/rules/naming.md (타입별)
|
||||
.claude/rules/testing.md (타입별)
|
||||
```
|
||||
|
||||
#### 4-2. settings.json (부분 갱신)
|
||||
- `deny` 목록: 글로벌 최신으로 교체
|
||||
- `allow` 목록: 기존 사용자 커스텀 유지 + 글로벌 기본값 병합
|
||||
- `hooks`: init-project SKILL.md의 hooks JSON 블록을 참조하여 교체 (없으면 추가)
|
||||
- SessionStart(compact) → on-post-compact.sh
|
||||
- PreCompact → on-pre-compact.sh
|
||||
- PostToolUse(Bash) → on-commit.sh
|
||||
|
||||
#### 4-3. 스킬 파일 (덮어쓰기)
|
||||
```
|
||||
.claude/skills/create-mr/SKILL.md
|
||||
.claude/skills/fix-issue/SKILL.md
|
||||
.claude/skills/sync-team-workflow/SKILL.md
|
||||
.claude/skills/init-project/SKILL.md
|
||||
```
|
||||
|
||||
#### 4-4. Git Hooks (덮어쓰기 + 실행 권한)
|
||||
```bash
|
||||
chmod +x .githooks/*
|
||||
```
|
||||
|
||||
#### 4-5. Hook 스크립트 갱신
|
||||
init-project SKILL.md의 코드 블록에서 최신 스크립트를 추출하여 덮어쓰기:
|
||||
```
|
||||
.claude/scripts/on-pre-compact.sh
|
||||
.claude/scripts/on-post-compact.sh
|
||||
.claude/scripts/on-commit.sh
|
||||
```
|
||||
실행 권한 부여: `chmod +x .claude/scripts/*.sh`
|
||||
|
||||
### 5. 로컬 버전 업데이트
|
||||
`.claude/workflow-version.json` 갱신:
|
||||
```json
|
||||
{
|
||||
"applied_global_version": "새버전",
|
||||
"applied_date": "오늘날짜",
|
||||
"project_type": "감지된타입",
|
||||
"gitea_url": "https://gitea.gc-si.dev"
|
||||
}
|
||||
```
|
||||
|
||||
### 6. 변경 보고
|
||||
- `git diff`로 변경 내역 확인
|
||||
- 업데이트된 파일 목록 출력
|
||||
- 변경 로그(글로벌 workflow-version.json의 changes) 표시
|
||||
- 필요한 추가 조치 안내 (빌드 확인, 의존성 업데이트 등)
|
||||
6
.claude/workflow-version.json
Normal file
6
.claude/workflow-version.json
Normal file
@ -0,0 +1,6 @@
|
||||
{
|
||||
"applied_global_version": "1.2.0",
|
||||
"applied_date": "2026-02-15",
|
||||
"project_type": "react-ts",
|
||||
"gitea_url": "https://gitea.gc-si.dev"
|
||||
}
|
||||
33
.editorconfig
Normal file
33
.editorconfig
Normal file
@ -0,0 +1,33 @@
|
||||
root = true
|
||||
|
||||
[*]
|
||||
charset = utf-8
|
||||
end_of_line = lf
|
||||
insert_final_newline = true
|
||||
trim_trailing_whitespace = true
|
||||
|
||||
[*.{java,kt}]
|
||||
indent_style = space
|
||||
indent_size = 4
|
||||
|
||||
[*.{js,jsx,ts,tsx,json,yml,yaml,css,scss,html}]
|
||||
indent_style = space
|
||||
indent_size = 2
|
||||
|
||||
[*.md]
|
||||
trim_trailing_whitespace = false
|
||||
|
||||
[*.{sh,bash}]
|
||||
indent_style = space
|
||||
indent_size = 4
|
||||
|
||||
[Makefile]
|
||||
indent_style = tab
|
||||
|
||||
[*.{gradle,groovy}]
|
||||
indent_style = space
|
||||
indent_size = 4
|
||||
|
||||
[*.xml]
|
||||
indent_style = space
|
||||
indent_size = 4
|
||||
36
.gitea/workflows/deploy.yml
Normal file
36
.gitea/workflows/deploy.yml
Normal file
@ -0,0 +1,36 @@
|
||||
name: Build and Deploy Wing
|
||||
|
||||
on:
|
||||
push:
|
||||
branches:
|
||||
- main
|
||||
|
||||
jobs:
|
||||
build-and-deploy:
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- name: Checkout
|
||||
uses: actions/checkout@v4
|
||||
|
||||
- name: Setup Node.js
|
||||
uses: actions/setup-node@v4
|
||||
with:
|
||||
node-version: '24'
|
||||
|
||||
- name: Configure npm registry
|
||||
run: |
|
||||
echo "registry=https://nexus.gc-si.dev/repository/npm-public/" > .npmrc
|
||||
echo "//nexus.gc-si.dev/repository/npm-public/:_auth=${{ secrets.NEXUS_NPM_AUTH }}" >> .npmrc
|
||||
|
||||
- name: Install dependencies
|
||||
run: npm ci
|
||||
|
||||
- name: Build web
|
||||
run: npm -w @wing/web run build
|
||||
|
||||
- name: Deploy to server
|
||||
run: |
|
||||
rm -rf /deploy/wing/*
|
||||
cp -r apps/web/dist/* /deploy/wing/
|
||||
echo "Deployed at $(date '+%Y-%m-%d %H:%M:%S')"
|
||||
ls -la /deploy/wing/
|
||||
60
.githooks/commit-msg
Executable file
60
.githooks/commit-msg
Executable file
@ -0,0 +1,60 @@
|
||||
#!/bin/bash
|
||||
#==============================================================================
|
||||
# commit-msg hook
|
||||
# Conventional Commits 형식 검증 (한/영 혼용 지원)
|
||||
#==============================================================================
|
||||
|
||||
COMMIT_MSG_FILE="$1"
|
||||
COMMIT_MSG=$(cat "$COMMIT_MSG_FILE")
|
||||
|
||||
# Merge 커밋은 검증 건너뜀
|
||||
if echo "$COMMIT_MSG" | head -1 | grep -qE "^Merge "; then
|
||||
exit 0
|
||||
fi
|
||||
|
||||
# Revert 커밋은 검증 건너뜀
|
||||
if echo "$COMMIT_MSG" | head -1 | grep -qE "^Revert "; then
|
||||
exit 0
|
||||
fi
|
||||
|
||||
# Conventional Commits 정규식
|
||||
# type(scope): subject
|
||||
# - type: feat|fix|docs|style|refactor|test|chore|ci|perf (필수)
|
||||
# - scope: 영문, 숫자, 한글, 점, 밑줄, 하이픈 허용 (선택)
|
||||
# - subject: 1~72자, 한/영 혼용 허용 (필수)
|
||||
PATTERN='^(feat|fix|docs|style|refactor|test|chore|ci|perf)(\([a-zA-Z0-9가-힣._-]+\))?: .{1,72}$'
|
||||
|
||||
FIRST_LINE=$(head -1 "$COMMIT_MSG_FILE")
|
||||
|
||||
if ! echo "$FIRST_LINE" | grep -qE "$PATTERN"; then
|
||||
echo ""
|
||||
echo "╔══════════════════════════════════════════════════════════════╗"
|
||||
echo "║ 커밋 메시지가 Conventional Commits 형식에 맞지 않습니다 ║"
|
||||
echo "╚══════════════════════════════════════════════════════════════╝"
|
||||
echo ""
|
||||
echo " 올바른 형식: type(scope): subject"
|
||||
echo ""
|
||||
echo " type (필수):"
|
||||
echo " feat — 새로운 기능"
|
||||
echo " fix — 버그 수정"
|
||||
echo " docs — 문서 변경"
|
||||
echo " style — 코드 포맷팅"
|
||||
echo " refactor — 리팩토링"
|
||||
echo " test — 테스트"
|
||||
echo " chore — 빌드/설정 변경"
|
||||
echo " ci — CI/CD 변경"
|
||||
echo " perf — 성능 개선"
|
||||
echo ""
|
||||
echo " scope (선택): 한/영 모두 가능"
|
||||
echo " subject (필수): 1~72자, 한/영 모두 가능"
|
||||
echo ""
|
||||
echo " 예시:"
|
||||
echo " feat(auth): JWT 기반 로그인 구현"
|
||||
echo " fix(배치): 야간 배치 타임아웃 수정"
|
||||
echo " docs: README 업데이트"
|
||||
echo " chore: Gradle 의존성 업데이트"
|
||||
echo ""
|
||||
echo " 현재 메시지: $FIRST_LINE"
|
||||
echo ""
|
||||
exit 1
|
||||
fi
|
||||
25
.githooks/post-checkout
Executable file
25
.githooks/post-checkout
Executable file
@ -0,0 +1,25 @@
|
||||
#!/bin/bash
|
||||
#==============================================================================
|
||||
# post-checkout hook
|
||||
# 브랜치 체크아웃 시 core.hooksPath 자동 설정
|
||||
# clone/checkout 후 .githooks 디렉토리가 있으면 자동으로 hooksPath 설정
|
||||
#==============================================================================
|
||||
|
||||
# post-checkout 파라미터: prev_HEAD, new_HEAD, branch_flag
|
||||
# branch_flag=1: 브랜치 체크아웃, 0: 파일 체크아웃
|
||||
BRANCH_FLAG="$3"
|
||||
|
||||
# 파일 체크아웃은 건너뜀
|
||||
if [ "$BRANCH_FLAG" = "0" ]; then
|
||||
exit 0
|
||||
fi
|
||||
|
||||
# .githooks 디렉토리 존재 확인
|
||||
REPO_ROOT=$(git rev-parse --show-toplevel 2>/dev/null)
|
||||
if [ -d "${REPO_ROOT}/.githooks" ]; then
|
||||
CURRENT_HOOKS_PATH=$(git config core.hooksPath 2>/dev/null || echo "")
|
||||
if [ "$CURRENT_HOOKS_PATH" != ".githooks" ]; then
|
||||
git config core.hooksPath .githooks
|
||||
chmod +x "${REPO_ROOT}/.githooks/"* 2>/dev/null
|
||||
fi
|
||||
fi
|
||||
61
.githooks/pre-commit
Executable file
61
.githooks/pre-commit
Executable file
@ -0,0 +1,61 @@
|
||||
#!/bin/bash
|
||||
#==============================================================================
|
||||
# pre-commit hook (모노레포 React TypeScript)
|
||||
# apps/web TypeScript 컴파일 + 린트 검증 — 실패 시 커밋 차단
|
||||
#==============================================================================
|
||||
|
||||
REPO_ROOT=$(git rev-parse --show-toplevel 2>/dev/null)
|
||||
|
||||
echo "pre-commit: TypeScript 타입 체크 중..."
|
||||
|
||||
# npm 확인
|
||||
if ! command -v npx &>/dev/null; then
|
||||
echo "경고: npx가 설치되지 않았습니다. 검증을 건너뜁니다."
|
||||
exit 0
|
||||
fi
|
||||
|
||||
# node_modules 확인
|
||||
if [ ! -d "${REPO_ROOT}/node_modules" ]; then
|
||||
echo "경고: node_modules가 없습니다. 'npm install' 실행 후 다시 시도하세요."
|
||||
exit 1
|
||||
fi
|
||||
|
||||
# apps/web TypeScript 타입 체크
|
||||
if [ -d "${REPO_ROOT}/apps/web" ]; then
|
||||
cd "${REPO_ROOT}/apps/web"
|
||||
npx tsc --noEmit --pretty 2>&1
|
||||
TSC_RESULT=$?
|
||||
|
||||
if [ $TSC_RESULT -ne 0 ]; then
|
||||
echo ""
|
||||
echo "╔══════════════════════════════════════════════════════════╗"
|
||||
echo "║ TypeScript 타입 에러! 커밋이 차단되었습니다. ║"
|
||||
echo "║ 타입 에러를 수정한 후 다시 커밋해주세요. ║"
|
||||
echo "╚══════════════════════════════════════════════════════════╝"
|
||||
echo ""
|
||||
exit 1
|
||||
fi
|
||||
|
||||
echo "pre-commit: apps/web 타입 체크 성공"
|
||||
|
||||
# ESLint 검증 (설정 파일이 있는 경우만)
|
||||
if [ -f "eslint.config.js" ] || [ -f "eslint.config.mjs" ] || [ -f ".eslintrc.js" ] || [ -f ".eslintrc.json" ] || [ -f ".eslintrc.cjs" ]; then
|
||||
echo "pre-commit: ESLint 검증 중..."
|
||||
npx eslint src/ --quiet 2>&1
|
||||
LINT_RESULT=$?
|
||||
|
||||
if [ $LINT_RESULT -ne 0 ]; then
|
||||
echo ""
|
||||
echo "╔══════════════════════════════════════════════════════════╗"
|
||||
echo "║ ESLint 에러! 커밋이 차단되었습니다. ║"
|
||||
echo "║ 'npm run lint -- --fix'로 자동 수정을 시도해보세요. ║"
|
||||
echo "╚══════════════════════════════════════════════════════════╝"
|
||||
echo ""
|
||||
exit 1
|
||||
fi
|
||||
|
||||
echo "pre-commit: ESLint 통과"
|
||||
fi
|
||||
fi
|
||||
|
||||
cd "${REPO_ROOT}"
|
||||
34
.gitignore
vendored
34
.gitignore
vendored
@ -1,17 +1,41 @@
|
||||
node_modules
|
||||
# === Dependencies ===
|
||||
node_modules/
|
||||
**/node_modules
|
||||
|
||||
dist
|
||||
# === Build ===
|
||||
dist/
|
||||
**/dist
|
||||
build/
|
||||
|
||||
.idea
|
||||
.vscode
|
||||
# === IDE ===
|
||||
.idea/
|
||||
.vscode/
|
||||
*.swp
|
||||
*.swo
|
||||
|
||||
# === OS ===
|
||||
.DS_Store
|
||||
Thumbs.db
|
||||
|
||||
# === Environment ===
|
||||
.env
|
||||
.env.*
|
||||
!.env.example
|
||||
secrets/
|
||||
|
||||
# === Test ===
|
||||
coverage/
|
||||
|
||||
# === Cache ===
|
||||
.eslintcache
|
||||
.prettiercache
|
||||
*.tsbuildinfo
|
||||
|
||||
# === Logs ===
|
||||
*.log
|
||||
|
||||
*.tsbuildinfo
|
||||
# === Claude Code ===
|
||||
# 글로벌 gitignore에서 .claude/ 전체를 무시하므로 팀 파일을 명시적으로 포함
|
||||
!.claude/
|
||||
.claude/settings.local.json
|
||||
.claude/CLAUDE.local.md
|
||||
|
||||
1
.node-version
Normal file
1
.node-version
Normal file
@ -0,0 +1 @@
|
||||
24
|
||||
2
.npmrc
Normal file
2
.npmrc
Normal file
@ -0,0 +1,2 @@
|
||||
registry=https://nexus.gc-si.dev/repository/npm-public/
|
||||
always-auth=true
|
||||
11
.prettierrc
Normal file
11
.prettierrc
Normal file
@ -0,0 +1,11 @@
|
||||
{
|
||||
"semi": true,
|
||||
"singleQuote": true,
|
||||
"trailingComma": "all",
|
||||
"printWidth": 100,
|
||||
"tabWidth": 2,
|
||||
"useTabs": false,
|
||||
"bracketSpacing": true,
|
||||
"arrowParens": "always",
|
||||
"endOfLine": "lf"
|
||||
}
|
||||
77
CLAUDE.md
Normal file
77
CLAUDE.md
Normal file
@ -0,0 +1,77 @@
|
||||
# Wing Fleet Dashboard (gc-wing)
|
||||
|
||||
## 프로젝트 개요
|
||||
|
||||
- **타입**: React + TypeScript + Vite (모노레포)
|
||||
- **Node.js**: `.node-version` 참조 (v24)
|
||||
- **패키지 매니저**: npm (workspaces)
|
||||
- **구조**: apps/web (프론트엔드) + apps/api (백엔드 API)
|
||||
|
||||
## 빌드 및 실행
|
||||
|
||||
```bash
|
||||
# 의존성 설치
|
||||
npm install
|
||||
|
||||
# 전체 개발 서버
|
||||
npm run dev
|
||||
|
||||
# 개별 개발 서버
|
||||
npm run dev:web # 프론트엔드 (Vite)
|
||||
npm run dev:api # 백엔드 (Fastify + tsx watch)
|
||||
|
||||
# 빌드
|
||||
npm run build # 전체 빌드 (web + api)
|
||||
npm run build:web # 프론트엔드만
|
||||
npm run build:api # 백엔드만
|
||||
|
||||
# 린트
|
||||
npm run lint # apps/web ESLint
|
||||
|
||||
# 데이터 준비
|
||||
npm run prepare:data
|
||||
```
|
||||
|
||||
## 프로젝트 구조
|
||||
|
||||
```
|
||||
gc-wing-dev/
|
||||
├── apps/
|
||||
│ ├── web/ # React 19 + Vite 7 + MapLibre + Deck.gl
|
||||
│ │ └── src/
|
||||
│ │ ├── app/ # App.tsx, styles
|
||||
│ │ ├── entities/ # 도메인 모델 (vessel, zone, aisTarget, legacyVessel)
|
||||
│ │ ├── features/ # 기능 단위 (mapToggles, typeFilter, aisPolling 등)
|
||||
│ │ ├── pages/ # 페이지 (DashboardPage)
|
||||
│ │ ├── shared/ # 공통 유틸 (lib/geo, lib/color, lib/map)
|
||||
│ │ └── widgets/ # UI 위젯 (map3d, vesselList, info, alarms 등)
|
||||
│ └── api/ # Fastify 5 + TypeScript
|
||||
│ └── src/
|
||||
│ └── index.ts
|
||||
├── data/ # 정적 데이터
|
||||
├── scripts/ # 빌드 스크립트 (prepare-zones, prepare-legacy)
|
||||
└── legacy/ # 레거시 데이터
|
||||
```
|
||||
|
||||
## 기술 스택
|
||||
|
||||
| 영역 | 기술 |
|
||||
|------|------|
|
||||
| 프론트엔드 | React 19, Vite 7, TypeScript 5.9 |
|
||||
| 지도 | MapLibre GL JS 5, Deck.gl 9 |
|
||||
| 백엔드 | Fastify 5, TypeScript |
|
||||
| 린트 | ESLint 9, Prettier |
|
||||
|
||||
## 팀 규칙
|
||||
|
||||
- 코드 스타일: `.claude/rules/code-style.md`
|
||||
- 네이밍 규칙: `.claude/rules/naming.md`
|
||||
- 테스트 규칙: `.claude/rules/testing.md`
|
||||
- Git 워크플로우: `.claude/rules/git-workflow.md`
|
||||
- 팀 정책: `.claude/rules/team-policy.md`
|
||||
|
||||
## 의존성 관리
|
||||
|
||||
- Nexus 프록시 레포지토리를 통해 npm 패키지 관리 (`.npmrc`)
|
||||
- 새 의존성 추가: `npm -w @wing/web install 패키지명`
|
||||
- devDependency: `npm -w @wing/web install -D 패키지명`
|
||||
@ -2,9 +2,9 @@
|
||||
<html lang="ko">
|
||||
<head>
|
||||
<meta charset="UTF-8" />
|
||||
<link rel="icon" type="image/svg+xml" href="/vite.svg" />
|
||||
<link rel="icon" type="image/svg+xml" href="/favicon.svg" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||
<title>906척 실시간 조업 감시 — 선단 연관관계</title>
|
||||
<title>WING 조업감시 데모</title>
|
||||
</head>
|
||||
<body>
|
||||
<div id="root"></div>
|
||||
|
||||
1
apps/web/public/data/subcables/cable-details.min.json
Normal file
1
apps/web/public/data/subcables/cable-details.min.json
Normal file
File diff suppressed because one or more lines are too long
1
apps/web/public/data/subcables/cable-geo.json
Normal file
1
apps/web/public/data/subcables/cable-geo.json
Normal file
File diff suppressed because one or more lines are too long
21
apps/web/public/favicon.svg
Normal file
21
apps/web/public/favicon.svg
Normal file
@ -0,0 +1,21 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 128 128" width="128" height="128" role="img" aria-label="WING">
|
||||
<defs>
|
||||
<linearGradient id="g" x1="20" y1="20" x2="108" y2="108" gradientUnits="userSpaceOnUse">
|
||||
<stop offset="0" stop-color="#60A5FA" />
|
||||
<stop offset="1" stop-color="#2563EB" />
|
||||
</linearGradient>
|
||||
</defs>
|
||||
|
||||
<circle cx="64" cy="64" r="58" fill="#0F172A" stroke="#1E3A5F" stroke-width="8" />
|
||||
|
||||
<!-- Stylized "W" mark -->
|
||||
<path
|
||||
d="M28 38 L44 92 L64 54 L84 92 L100 38"
|
||||
fill="none"
|
||||
stroke="url(#g)"
|
||||
stroke-width="14"
|
||||
stroke-linecap="round"
|
||||
stroke-linejoin="round"
|
||||
/>
|
||||
</svg>
|
||||
|
||||
|
After Width: | Height: | 크기: 640 B |
@ -1,6 +1,7 @@
|
||||
{
|
||||
"version": 8,
|
||||
"name": "CARTO Dark (Legacy)",
|
||||
"glyphs": "https://demotiles.maplibre.org/font/{fontstack}/{range}.pbf",
|
||||
"sources": {
|
||||
"carto-dark": {
|
||||
"type": "raster",
|
||||
|
||||
@ -1,6 +1,7 @@
|
||||
{
|
||||
"version": 8,
|
||||
"name": "OSM Raster + OpenSeaMap",
|
||||
"glyphs": "https://demotiles.maplibre.org/font/{fontstack}/{range}.pbf",
|
||||
"sources": {
|
||||
"osm": {
|
||||
"type": "raster",
|
||||
|
||||
@ -121,6 +121,30 @@ body {
|
||||
margin-bottom: 6px;
|
||||
}
|
||||
|
||||
.sb-t-row {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
gap: 8px;
|
||||
}
|
||||
|
||||
.relation-sort {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 6px;
|
||||
font-size: 8px;
|
||||
color: var(--muted);
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.relation-sort__option {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 3px;
|
||||
cursor: pointer;
|
||||
user-select: none;
|
||||
}
|
||||
|
||||
/* Type grid */
|
||||
.tg {
|
||||
display: grid;
|
||||
@ -215,6 +239,21 @@ body {
|
||||
background: var(--card);
|
||||
}
|
||||
|
||||
.vi.sel {
|
||||
background: rgba(14, 234, 255, 0.16);
|
||||
border-color: rgba(14, 234, 255, 0.55);
|
||||
}
|
||||
|
||||
.vi.hl {
|
||||
background: rgba(245, 158, 11, 0.16);
|
||||
border: 1px solid rgba(245, 158, 11, 0.4);
|
||||
}
|
||||
|
||||
.vi.sel.hl {
|
||||
background: linear-gradient(90deg, rgba(14, 234, 255, 0.16), rgba(245, 158, 11, 0.16));
|
||||
border-color: rgba(14, 234, 255, 0.7);
|
||||
}
|
||||
|
||||
.vi .dot {
|
||||
width: 7px;
|
||||
height: 7px;
|
||||
@ -426,6 +465,75 @@ body {
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
/* Alarm filter (dropdown) */
|
||||
.alarm-filter {
|
||||
position: relative;
|
||||
}
|
||||
|
||||
.alarm-filter__summary {
|
||||
list-style: none;
|
||||
cursor: pointer;
|
||||
padding: 2px 8px;
|
||||
border-radius: 6px;
|
||||
border: 1px solid var(--border);
|
||||
background: rgba(30, 41, 59, 0.55);
|
||||
color: var(--text);
|
||||
font-size: 8px;
|
||||
font-weight: 700;
|
||||
letter-spacing: 0.4px;
|
||||
user-select: none;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.alarm-filter__summary::-webkit-details-marker {
|
||||
display: none;
|
||||
}
|
||||
|
||||
.alarm-filter__menu {
|
||||
position: absolute;
|
||||
right: 0;
|
||||
top: 22px;
|
||||
z-index: 2000;
|
||||
min-width: 170px;
|
||||
padding: 6px;
|
||||
border-radius: 10px;
|
||||
border: 1px solid var(--border);
|
||||
background: rgba(15, 23, 42, 0.98);
|
||||
box-shadow: 0 16px 50px rgba(0, 0, 0, 0.55);
|
||||
}
|
||||
|
||||
.alarm-filter__row {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 6px;
|
||||
padding: 4px 6px;
|
||||
border-radius: 6px;
|
||||
cursor: pointer;
|
||||
font-size: 10px;
|
||||
color: var(--text);
|
||||
user-select: none;
|
||||
}
|
||||
|
||||
.alarm-filter__row:hover {
|
||||
background: rgba(59, 130, 246, 0.08);
|
||||
}
|
||||
|
||||
.alarm-filter__row input {
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.alarm-filter__cnt {
|
||||
margin-left: auto;
|
||||
font-size: 9px;
|
||||
color: var(--muted);
|
||||
}
|
||||
|
||||
.alarm-filter__sep {
|
||||
height: 1px;
|
||||
background: rgba(30, 58, 95, 0.85);
|
||||
margin: 4px 0;
|
||||
}
|
||||
|
||||
/* Relation panel */
|
||||
.rel-panel {
|
||||
background: var(--card);
|
||||
@ -488,6 +596,13 @@ body {
|
||||
margin-bottom: 4px;
|
||||
}
|
||||
|
||||
.fleet-card.hl,
|
||||
.fleet-card:hover {
|
||||
border-color: rgba(245, 158, 11, 0.75);
|
||||
background: rgba(251, 191, 36, 0.09);
|
||||
box-shadow: 0 0 0 1px rgba(245, 158, 11, 0.25) inset;
|
||||
}
|
||||
|
||||
.fleet-owner {
|
||||
font-size: 10px;
|
||||
font-weight: 700;
|
||||
@ -495,6 +610,10 @@ body {
|
||||
margin-bottom: 4px;
|
||||
}
|
||||
|
||||
.fleet-owner.hl {
|
||||
color: rgba(245, 158, 11, 1);
|
||||
}
|
||||
|
||||
.fleet-vessel {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
@ -503,6 +622,14 @@ body {
|
||||
padding: 1px 0;
|
||||
}
|
||||
|
||||
.fleet-vessel.hl {
|
||||
color: rgba(245, 158, 11, 1);
|
||||
}
|
||||
|
||||
.fleet-dot.hl {
|
||||
box-shadow: 0 0 0 2px rgba(245, 158, 11, 0.45);
|
||||
}
|
||||
|
||||
/* Toggles */
|
||||
.tog {
|
||||
display: flex;
|
||||
@ -511,6 +638,19 @@ body {
|
||||
margin-bottom: 6px;
|
||||
}
|
||||
|
||||
.tog.tog-map {
|
||||
/* Keep "지도 표시 설정" buttons in a predictable 2-row layout (4 columns). */
|
||||
gap: 4px;
|
||||
}
|
||||
|
||||
.tog.tog-map .tog-btn {
|
||||
flex: 1 1 calc(25% - 4px);
|
||||
text-align: center;
|
||||
white-space: nowrap;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
}
|
||||
|
||||
.tog-btn {
|
||||
font-size: 8px;
|
||||
padding: 2px 6px;
|
||||
@ -596,6 +736,124 @@ body {
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
.map-loader-overlay {
|
||||
position: absolute;
|
||||
inset: 0;
|
||||
z-index: 950;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
background: rgba(2, 6, 23, 0.42);
|
||||
pointer-events: auto;
|
||||
}
|
||||
|
||||
.map-loader-overlay__panel {
|
||||
width: min(72vw, 320px);
|
||||
background: rgba(15, 23, 42, 0.94);
|
||||
border: 1px solid var(--border);
|
||||
border-radius: 12px;
|
||||
padding: 14px 16px;
|
||||
display: grid;
|
||||
gap: 10px;
|
||||
justify-items: center;
|
||||
}
|
||||
|
||||
.map-loader-overlay__spinner {
|
||||
width: 28px;
|
||||
height: 28px;
|
||||
border: 3px solid rgba(148, 163, 184, 0.28);
|
||||
border-top-color: var(--accent);
|
||||
border-radius: 50%;
|
||||
animation: map-loader-spin 0.7s linear infinite;
|
||||
}
|
||||
|
||||
.map-loader-overlay__text {
|
||||
font-size: 12px;
|
||||
color: var(--text);
|
||||
letter-spacing: 0.2px;
|
||||
}
|
||||
|
||||
.map-loader-overlay__bar {
|
||||
width: 100%;
|
||||
height: 6px;
|
||||
border-radius: 999px;
|
||||
background: rgba(148, 163, 184, 0.2);
|
||||
overflow: hidden;
|
||||
position: relative;
|
||||
}
|
||||
|
||||
.map-loader-overlay__fill {
|
||||
width: 28%;
|
||||
height: 100%;
|
||||
border-radius: inherit;
|
||||
background: var(--accent);
|
||||
animation: map-loader-fill 1.2s ease-in-out infinite;
|
||||
}
|
||||
|
||||
.maplibre-tooltip-popup .maplibregl-popup-content {
|
||||
color: #f8fafc !important;
|
||||
background: rgba(2, 6, 23, 0.98) !important;
|
||||
border: 1px solid rgba(148, 163, 184, 0.4) !important;
|
||||
box-shadow: 0 8px 26px rgba(2, 6, 23, 0.55) !important;
|
||||
border-radius: 8px !important;
|
||||
font-size: 11px !important;
|
||||
line-height: 1.35 !important;
|
||||
padding: 7px 9px !important;
|
||||
color: #f8fafc !important;
|
||||
min-width: 180px;
|
||||
}
|
||||
|
||||
.maplibre-tooltip-popup .maplibregl-popup-tip {
|
||||
border-top-color: rgba(2, 6, 23, 0.97) !important;
|
||||
}
|
||||
|
||||
.maplibre-tooltip-popup__content {
|
||||
color: #f8fafc;
|
||||
font-family: Pretendard, Inter, ui-sans-serif, -apple-system, Segoe UI, sans-serif;
|
||||
font-size: 11px;
|
||||
line-height: 1.35;
|
||||
}
|
||||
|
||||
.maplibre-tooltip-popup__content div,
|
||||
.maplibre-tooltip-popup__content span,
|
||||
.maplibre-tooltip-popup__content p {
|
||||
color: inherit;
|
||||
}
|
||||
|
||||
.maplibre-tooltip-popup__content div {
|
||||
word-break: break-word;
|
||||
}
|
||||
|
||||
.maplibre-tooltip-popup .maplibregl-popup-content div,
|
||||
.maplibre-tooltip-popup .maplibregl-popup-content span,
|
||||
.maplibre-tooltip-popup .maplibregl-popup-content p {
|
||||
color: inherit !important;
|
||||
}
|
||||
|
||||
.maplibre-tooltip-popup .maplibregl-popup-close-button {
|
||||
color: #94a3b8 !important;
|
||||
}
|
||||
|
||||
@keyframes map-loader-spin {
|
||||
to {
|
||||
transform: rotate(360deg);
|
||||
}
|
||||
}
|
||||
|
||||
@keyframes map-loader-fill {
|
||||
0% {
|
||||
transform: translateX(-40%);
|
||||
}
|
||||
|
||||
50% {
|
||||
transform: translateX(220%);
|
||||
}
|
||||
|
||||
100% {
|
||||
transform: translateX(-40%);
|
||||
}
|
||||
}
|
||||
|
||||
.close-btn {
|
||||
position: absolute;
|
||||
top: 6px;
|
||||
@ -663,6 +921,182 @@ body {
|
||||
border-radius: 8px;
|
||||
}
|
||||
|
||||
/* ── Map Settings Panel ────────────────────────────────────────────── */
|
||||
|
||||
.map-settings-gear {
|
||||
position: absolute;
|
||||
top: 100px;
|
||||
left: 10px;
|
||||
z-index: 850;
|
||||
width: 29px;
|
||||
height: 29px;
|
||||
border-radius: 4px;
|
||||
border: 1px solid var(--border);
|
||||
background: rgba(15, 23, 42, 0.92);
|
||||
backdrop-filter: blur(8px);
|
||||
color: var(--muted);
|
||||
cursor: pointer;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
font-size: 16px;
|
||||
transition: color 0.15s, border-color 0.15s;
|
||||
user-select: none;
|
||||
padding: 0;
|
||||
}
|
||||
|
||||
.map-settings-gear:hover {
|
||||
color: var(--text);
|
||||
border-color: var(--accent);
|
||||
}
|
||||
|
||||
.map-settings-gear.open {
|
||||
color: var(--accent);
|
||||
border-color: var(--accent);
|
||||
}
|
||||
|
||||
.map-settings-panel {
|
||||
position: absolute;
|
||||
top: 10px;
|
||||
left: 48px;
|
||||
z-index: 850;
|
||||
background: rgba(15, 23, 42, 0.95);
|
||||
backdrop-filter: blur(8px);
|
||||
border: 1px solid var(--border);
|
||||
border-radius: 8px;
|
||||
padding: 12px;
|
||||
width: 240px;
|
||||
max-height: calc(100vh - 80px);
|
||||
overflow-y: auto;
|
||||
}
|
||||
|
||||
.map-settings-panel .ms-title {
|
||||
font-size: 11px;
|
||||
font-weight: 700;
|
||||
color: var(--text);
|
||||
letter-spacing: 1px;
|
||||
margin-bottom: 10px;
|
||||
}
|
||||
|
||||
.map-settings-panel .ms-section {
|
||||
margin-bottom: 10px;
|
||||
}
|
||||
|
||||
.map-settings-panel .ms-label {
|
||||
font-size: 10px;
|
||||
font-weight: 600;
|
||||
color: var(--text);
|
||||
letter-spacing: 0.5px;
|
||||
margin-bottom: 5px;
|
||||
}
|
||||
|
||||
.map-settings-panel .ms-row {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
margin-bottom: 4px;
|
||||
}
|
||||
|
||||
.map-settings-panel .ms-color-input {
|
||||
width: 24px;
|
||||
height: 24px;
|
||||
border: 1px solid var(--border);
|
||||
border-radius: 4px;
|
||||
padding: 0;
|
||||
cursor: pointer;
|
||||
background: transparent;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.map-settings-panel .ms-color-input::-webkit-color-swatch-wrapper {
|
||||
padding: 1px;
|
||||
}
|
||||
|
||||
.map-settings-panel .ms-color-input::-webkit-color-swatch {
|
||||
border: none;
|
||||
border-radius: 2px;
|
||||
}
|
||||
|
||||
.map-settings-panel .ms-hex {
|
||||
font-size: 9px;
|
||||
color: #94a3b8;
|
||||
font-family: monospace;
|
||||
}
|
||||
|
||||
.map-settings-panel .ms-depth-label {
|
||||
font-size: 10px;
|
||||
color: var(--text);
|
||||
min-width: 48px;
|
||||
text-align: right;
|
||||
}
|
||||
|
||||
.map-settings-panel select {
|
||||
font-size: 10px;
|
||||
padding: 4px 8px;
|
||||
border-radius: 4px;
|
||||
border: 1px solid var(--border);
|
||||
background: var(--card);
|
||||
color: var(--text);
|
||||
cursor: pointer;
|
||||
outline: none;
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
.map-settings-panel select:focus {
|
||||
border-color: var(--accent);
|
||||
}
|
||||
|
||||
.map-settings-panel .ms-reset {
|
||||
width: 100%;
|
||||
font-size: 9px;
|
||||
padding: 5px 8px;
|
||||
border-radius: 4px;
|
||||
border: 1px solid var(--border);
|
||||
background: var(--card);
|
||||
color: var(--muted);
|
||||
cursor: pointer;
|
||||
transition: all 0.15s;
|
||||
margin-top: 4px;
|
||||
}
|
||||
|
||||
.map-settings-panel .ms-reset:hover {
|
||||
color: var(--text);
|
||||
border-color: var(--accent);
|
||||
}
|
||||
|
||||
/* ── Depth Legend ──────────────────────────────────────────────────── */
|
||||
|
||||
.depth-legend {
|
||||
position: absolute;
|
||||
bottom: 44px;
|
||||
left: 10px;
|
||||
z-index: 800;
|
||||
background: rgba(15, 23, 42, 0.92);
|
||||
backdrop-filter: blur(8px);
|
||||
border: 1px solid var(--border);
|
||||
border-radius: 8px;
|
||||
padding: 8px;
|
||||
display: flex;
|
||||
gap: 6px;
|
||||
align-items: stretch;
|
||||
}
|
||||
|
||||
.depth-legend__bar {
|
||||
width: 14px;
|
||||
border-radius: 3px;
|
||||
min-height: 120px;
|
||||
}
|
||||
|
||||
.depth-legend__ticks {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
justify-content: space-between;
|
||||
font-size: 8px;
|
||||
color: var(--muted);
|
||||
font-family: monospace;
|
||||
padding: 1px 0;
|
||||
}
|
||||
|
||||
@media (max-width: 920px) {
|
||||
.app {
|
||||
grid-template-columns: 1fr;
|
||||
|
||||
@ -3,6 +3,9 @@ import type { AisTargetSearchResponse } from "../model/types";
|
||||
export type SearchAisTargetsParams = {
|
||||
minutes: number;
|
||||
bbox?: string;
|
||||
centerLon?: number;
|
||||
centerLat?: number;
|
||||
radiusMeters?: number;
|
||||
};
|
||||
|
||||
export async function searchAisTargets(params: SearchAisTargetsParams, signal?: AbortSignal) {
|
||||
@ -13,6 +16,15 @@ export async function searchAisTargets(params: SearchAisTargetsParams, signal?:
|
||||
const u = new URL(`${base}/api/ais-target/search`, window.location.origin);
|
||||
u.searchParams.set("minutes", String(params.minutes));
|
||||
if (params.bbox) u.searchParams.set("bbox", params.bbox);
|
||||
if (typeof params.centerLon === "number" && Number.isFinite(params.centerLon)) {
|
||||
u.searchParams.set("centerLon", String(params.centerLon));
|
||||
}
|
||||
if (typeof params.centerLat === "number" && Number.isFinite(params.centerLat)) {
|
||||
u.searchParams.set("centerLat", String(params.centerLat));
|
||||
}
|
||||
if (typeof params.radiusMeters === "number" && Number.isFinite(params.radiusMeters)) {
|
||||
u.searchParams.set("radiusMeters", String(params.radiusMeters));
|
||||
}
|
||||
|
||||
const res = await fetch(u, { signal, headers: { accept: "application/json" } });
|
||||
const txt = await res.text();
|
||||
|
||||
52
apps/web/src/entities/subcable/api/useSubcables.ts
Normal file
52
apps/web/src/entities/subcable/api/useSubcables.ts
Normal file
@ -0,0 +1,52 @@
|
||||
import { useEffect, useState } from 'react';
|
||||
import type { SubcableGeoJson, SubcableDetailsIndex, SubcableDetail } from '../model/types';
|
||||
|
||||
interface SubcableData {
|
||||
geo: SubcableGeoJson;
|
||||
details: Map<string, SubcableDetail>;
|
||||
}
|
||||
|
||||
export function useSubcables(
|
||||
geoUrl = '/data/subcables/cable-geo.json',
|
||||
detailsUrl = '/data/subcables/cable-details.min.json',
|
||||
) {
|
||||
const [data, setData] = useState<SubcableData | null>(null);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
|
||||
useEffect(() => {
|
||||
let cancelled = false;
|
||||
|
||||
async function run() {
|
||||
try {
|
||||
setError(null);
|
||||
const [geoRes, detailsRes] = await Promise.all([
|
||||
fetch(geoUrl),
|
||||
fetch(detailsUrl),
|
||||
]);
|
||||
if (!geoRes.ok) throw new Error(`Failed to load subcable geo: ${geoRes.status}`);
|
||||
if (!detailsRes.ok) throw new Error(`Failed to load subcable details: ${detailsRes.status}`);
|
||||
|
||||
const geo = (await geoRes.json()) as SubcableGeoJson;
|
||||
const detailsJson = (await detailsRes.json()) as SubcableDetailsIndex;
|
||||
if (cancelled) return;
|
||||
|
||||
const details = new Map<string, SubcableDetail>();
|
||||
for (const [id, detail] of Object.entries(detailsJson.by_id)) {
|
||||
details.set(id, detail);
|
||||
}
|
||||
|
||||
setData({ geo, details });
|
||||
} catch (e) {
|
||||
if (cancelled) return;
|
||||
setError(e instanceof Error ? e.message : String(e));
|
||||
}
|
||||
}
|
||||
|
||||
void run();
|
||||
return () => {
|
||||
cancelled = true;
|
||||
};
|
||||
}, [geoUrl, detailsUrl]);
|
||||
|
||||
return { data, error };
|
||||
}
|
||||
39
apps/web/src/entities/subcable/model/types.ts
Normal file
39
apps/web/src/entities/subcable/model/types.ts
Normal file
@ -0,0 +1,39 @@
|
||||
export interface SubcableFeatureProperties {
|
||||
id: string;
|
||||
name: string;
|
||||
color: string;
|
||||
feature_id: string;
|
||||
coordinates: [number, number];
|
||||
}
|
||||
|
||||
export type SubcableGeoJson = GeoJSON.FeatureCollection<
|
||||
GeoJSON.MultiLineString,
|
||||
SubcableFeatureProperties
|
||||
>;
|
||||
|
||||
export interface SubcableLandingPoint {
|
||||
id: string;
|
||||
name: string;
|
||||
country: string;
|
||||
is_tbd: boolean;
|
||||
}
|
||||
|
||||
export interface SubcableDetail {
|
||||
id: string;
|
||||
name: string;
|
||||
length: string | null;
|
||||
rfs: string | null;
|
||||
rfs_year: number | null;
|
||||
is_planned: boolean;
|
||||
owners: string | null;
|
||||
suppliers: string | null;
|
||||
landing_points: SubcableLandingPoint[];
|
||||
notes: string | null;
|
||||
url: string | null;
|
||||
}
|
||||
|
||||
export interface SubcableDetailsIndex {
|
||||
version: number;
|
||||
generated_at: string;
|
||||
by_id: Record<string, SubcableDetail>;
|
||||
}
|
||||
@ -18,10 +18,14 @@ export type AisPollingSnapshot = {
|
||||
|
||||
export type AisPollingOptions = {
|
||||
initialMinutes?: number;
|
||||
bootstrapMinutes?: number;
|
||||
incrementalMinutes?: number;
|
||||
intervalMs?: number;
|
||||
retentionMinutes?: number;
|
||||
bbox?: string;
|
||||
centerLon?: number;
|
||||
centerLat?: number;
|
||||
radiusMeters?: number;
|
||||
enabled?: boolean;
|
||||
};
|
||||
|
||||
@ -40,10 +44,10 @@ function upsertByMmsi(store: Map<number, AisTarget>, rows: AisTarget[]) {
|
||||
continue;
|
||||
}
|
||||
|
||||
// Prefer newer records if the upstream ever returns stale items.
|
||||
const prevTs = prev.messageTimestamp ?? "";
|
||||
const nextTs = r.messageTimestamp ?? "";
|
||||
if (nextTs && prevTs && nextTs < prevTs) continue;
|
||||
// Keep newer rows only. If backend returns same/older timestamp, skip.
|
||||
const prevTs = Date.parse(prev.messageTimestamp || "");
|
||||
const nextTs = Date.parse(r.messageTimestamp || "");
|
||||
if (Number.isFinite(prevTs) && Number.isFinite(nextTs) && nextTs <= prevTs) continue;
|
||||
|
||||
store.set(r.mmsi, r);
|
||||
upserted += 1;
|
||||
@ -109,14 +113,18 @@ function pruneStore(store: Map<number, AisTarget>, retentionMinutes: number, bbo
|
||||
|
||||
export function useAisTargetPolling(opts: AisPollingOptions = {}) {
|
||||
const initialMinutes = opts.initialMinutes ?? 60;
|
||||
const bootstrapMinutes = opts.bootstrapMinutes ?? initialMinutes;
|
||||
const incrementalMinutes = opts.incrementalMinutes ?? 1;
|
||||
const intervalMs = opts.intervalMs ?? 60_000;
|
||||
const retentionMinutes = opts.retentionMinutes ?? initialMinutes;
|
||||
const enabled = opts.enabled ?? true;
|
||||
const bbox = opts.bbox;
|
||||
const centerLon = opts.centerLon;
|
||||
const centerLat = opts.centerLat;
|
||||
const radiusMeters = opts.radiusMeters;
|
||||
|
||||
const storeRef = useRef<Map<number, AisTarget>>(new Map());
|
||||
const inFlightRef = useRef(false);
|
||||
const generationRef = useRef(0);
|
||||
|
||||
const [rev, setRev] = useState(0);
|
||||
const [snapshot, setSnapshot] = useState<AisPollingSnapshot>({
|
||||
@ -136,15 +144,23 @@ export function useAisTargetPolling(opts: AisPollingOptions = {}) {
|
||||
|
||||
let cancelled = false;
|
||||
const controller = new AbortController();
|
||||
const generation = ++generationRef.current;
|
||||
|
||||
async function run(minutes: number) {
|
||||
if (inFlightRef.current) return;
|
||||
inFlightRef.current = true;
|
||||
async function run(minutes: number, context: "bootstrap" | "initial" | "incremental") {
|
||||
try {
|
||||
setSnapshot((s) => ({ ...s, status: s.status === "idle" ? "loading" : s.status, error: null }));
|
||||
|
||||
const res = await searchAisTargets({ minutes, bbox }, controller.signal);
|
||||
if (cancelled) return;
|
||||
const res = await searchAisTargets(
|
||||
{
|
||||
minutes,
|
||||
bbox,
|
||||
centerLon,
|
||||
centerLat,
|
||||
radiusMeters,
|
||||
},
|
||||
controller.signal,
|
||||
);
|
||||
if (cancelled || generation !== generationRef.current) return;
|
||||
|
||||
const { inserted, upserted } = upsertByMmsi(storeRef.current, res.data);
|
||||
const deleted = pruneStore(storeRef.current, retentionMinutes, bbox);
|
||||
@ -164,14 +180,12 @@ export function useAisTargetPolling(opts: AisPollingOptions = {}) {
|
||||
});
|
||||
setRev((r) => r + 1);
|
||||
} catch (e) {
|
||||
if (cancelled) return;
|
||||
if (cancelled || generation !== generationRef.current) return;
|
||||
setSnapshot((s) => ({
|
||||
...s,
|
||||
status: "error",
|
||||
status: context === "incremental" ? s.status : "error",
|
||||
error: e instanceof Error ? e.message : String(e),
|
||||
}));
|
||||
} finally {
|
||||
inFlightRef.current = false;
|
||||
}
|
||||
}
|
||||
|
||||
@ -190,15 +204,30 @@ export function useAisTargetPolling(opts: AisPollingOptions = {}) {
|
||||
});
|
||||
setRev((r) => r + 1);
|
||||
|
||||
void run(initialMinutes);
|
||||
const id = window.setInterval(() => void run(incrementalMinutes), intervalMs);
|
||||
void run(bootstrapMinutes, "bootstrap");
|
||||
if (bootstrapMinutes !== initialMinutes) {
|
||||
void run(initialMinutes, "initial");
|
||||
}
|
||||
|
||||
const id = window.setInterval(() => void run(incrementalMinutes, "incremental"), intervalMs);
|
||||
|
||||
return () => {
|
||||
cancelled = true;
|
||||
controller.abort();
|
||||
window.clearInterval(id);
|
||||
};
|
||||
}, [initialMinutes, incrementalMinutes, intervalMs, retentionMinutes, bbox, enabled]);
|
||||
}, [
|
||||
initialMinutes,
|
||||
bootstrapMinutes,
|
||||
incrementalMinutes,
|
||||
intervalMs,
|
||||
retentionMinutes,
|
||||
bbox,
|
||||
centerLon,
|
||||
centerLat,
|
||||
radiusMeters,
|
||||
enabled,
|
||||
]);
|
||||
|
||||
const targets = useMemo(() => {
|
||||
// `rev` is a version counter so we recompute the array snapshot when the store changes.
|
||||
|
||||
@ -188,12 +188,17 @@ export function computeFleetCircles(vessels: DerivedLegacyVessel[]): FleetCircle
|
||||
const out: FleetCircle[] = [];
|
||||
for (const [ownerKey, vs] of groups.entries()) {
|
||||
if (vs.length < 3) continue;
|
||||
const ownerLabel =
|
||||
vs.find((v) => v.ownerCn)?.ownerCn ??
|
||||
vs.find((v) => v.ownerRoman)?.ownerRoman ??
|
||||
ownerKey;
|
||||
const lon = vs.reduce((sum, v) => sum + v.lon, 0) / vs.length;
|
||||
const lat = vs.reduce((sum, v) => sum + v.lat, 0) / vs.length;
|
||||
let radiusNm = 0;
|
||||
for (const v of vs) radiusNm = Math.max(radiusNm, haversineNm(lat, lon, v.lat, v.lon));
|
||||
out.push({
|
||||
ownerKey,
|
||||
ownerLabel,
|
||||
center: [lon, lat],
|
||||
radiusNm: Math.max(0.2, radiusNm),
|
||||
count: vs.length,
|
||||
@ -296,14 +301,33 @@ export function computeLegacyAlarms(args: {
|
||||
});
|
||||
}
|
||||
|
||||
// Most recent first by timeLabel (approx), then by severity.
|
||||
const sevScore = (s: "cr" | "hi") => (s === "cr" ? 2 : 1);
|
||||
// Fixed category priority (independent of severity):
|
||||
// 1) 수역 이탈 2) 쌍 이격 경고 3) 환적 의심 4) 휴어기 조업 의심 5) AIS 지연
|
||||
// Within each category: most recent first (smaller N in "-N분" is more recent).
|
||||
const kindPriority: Record<LegacyAlarm["kind"], number> = {
|
||||
zone_violation: 0,
|
||||
pair_separation: 1,
|
||||
transshipment: 2,
|
||||
closed_season: 3,
|
||||
ais_stale: 4,
|
||||
};
|
||||
|
||||
const parseAgeMin = (label: string) => {
|
||||
if (label === "방금") return 0;
|
||||
const m = /-(\\d+)분/.exec(label);
|
||||
if (m) return Number(m[1]);
|
||||
return Number.POSITIVE_INFINITY;
|
||||
};
|
||||
|
||||
alarms.sort((a, b) => {
|
||||
const av = sevScore(b.severity) - sevScore(a.severity);
|
||||
if (av !== 0) return av;
|
||||
const at = Number(a.timeLabel.replace(/[^0-9]/g, "")) || 0;
|
||||
const bt = Number(b.timeLabel.replace(/[^0-9]/g, "")) || 0;
|
||||
return at - bt;
|
||||
const ak = kindPriority[a.kind] ?? 999;
|
||||
const bk = kindPriority[b.kind] ?? 999;
|
||||
if (ak !== bk) return ak - bk;
|
||||
const am = parseAgeMin(a.timeLabel);
|
||||
const bm = parseAgeMin(b.timeLabel);
|
||||
if (am !== bm) return am - bm;
|
||||
// Stable tie-break.
|
||||
return a.text.localeCompare(b.text);
|
||||
});
|
||||
|
||||
return alarms;
|
||||
|
||||
@ -56,6 +56,7 @@ export type FcLink = {
|
||||
|
||||
export type FleetCircle = {
|
||||
ownerKey: string;
|
||||
ownerLabel: string;
|
||||
center: [number, number];
|
||||
radiusNm: number;
|
||||
count: number;
|
||||
@ -72,3 +73,18 @@ export type LegacyAlarm = {
|
||||
relatedMmsi: number[];
|
||||
};
|
||||
|
||||
export const LEGACY_ALARM_KINDS: LegacyAlarmKind[] = [
|
||||
"pair_separation",
|
||||
"transshipment",
|
||||
"closed_season",
|
||||
"ais_stale",
|
||||
"zone_violation",
|
||||
];
|
||||
|
||||
export const LEGACY_ALARM_KIND_LABEL: Record<LegacyAlarmKind, string> = {
|
||||
pair_separation: "쌍 이격 경고",
|
||||
transshipment: "환적 의심",
|
||||
closed_season: "휴어기 조업 의심",
|
||||
ais_stale: "AIS 지연",
|
||||
zone_violation: "수역 이탈",
|
||||
};
|
||||
|
||||
221
apps/web/src/features/mapSettings/MapSettingsPanel.tsx
Normal file
221
apps/web/src/features/mapSettings/MapSettingsPanel.tsx
Normal file
@ -0,0 +1,221 @@
|
||||
import { useState } from 'react';
|
||||
import type { MapStyleSettings, MapLabelLanguage, DepthFontSize, DepthColorStop } from './types';
|
||||
import { DEFAULT_MAP_STYLE_SETTINGS } from './types';
|
||||
|
||||
interface MapSettingsPanelProps {
|
||||
value: MapStyleSettings;
|
||||
onChange: (next: MapStyleSettings) => void;
|
||||
}
|
||||
|
||||
const LANGUAGES: { value: MapLabelLanguage; label: string }[] = [
|
||||
{ value: 'ko', label: '한국어' },
|
||||
{ value: 'en', label: 'English' },
|
||||
{ value: 'ja', label: '日本語' },
|
||||
{ value: 'zh', label: '中文' },
|
||||
{ value: 'local', label: '현지어' },
|
||||
];
|
||||
|
||||
const FONT_SIZES: { value: DepthFontSize; label: string }[] = [
|
||||
{ value: 'small', label: '소' },
|
||||
{ value: 'medium', label: '중' },
|
||||
{ value: 'large', label: '대' },
|
||||
];
|
||||
|
||||
function depthLabel(depth: number): string {
|
||||
return `${Math.abs(depth).toLocaleString()}m`;
|
||||
}
|
||||
|
||||
function hexToRgb(hex: string): [number, number, number] {
|
||||
return [
|
||||
parseInt(hex.slice(1, 3), 16),
|
||||
parseInt(hex.slice(3, 5), 16),
|
||||
parseInt(hex.slice(5, 7), 16),
|
||||
];
|
||||
}
|
||||
|
||||
function rgbToHex(r: number, g: number, b: number): string {
|
||||
return `#${[r, g, b].map((c) => Math.round(Math.max(0, Math.min(255, c))).toString(16).padStart(2, '0')).join('')}`;
|
||||
}
|
||||
|
||||
function interpolateGradient(stops: DepthColorStop[]): DepthColorStop[] {
|
||||
if (stops.length < 2) return stops;
|
||||
const sorted = [...stops].sort((a, b) => a.depth - b.depth);
|
||||
const first = sorted[0];
|
||||
const last = sorted[sorted.length - 1];
|
||||
const [r1, g1, b1] = hexToRgb(first.color);
|
||||
const [r2, g2, b2] = hexToRgb(last.color);
|
||||
return sorted.map((stop, i) => {
|
||||
if (i === 0 || i === sorted.length - 1) return stop;
|
||||
const t = i / (sorted.length - 1);
|
||||
return {
|
||||
depth: stop.depth,
|
||||
color: rgbToHex(
|
||||
r1 + (r2 - r1) * t,
|
||||
g1 + (g2 - g1) * t,
|
||||
b1 + (b2 - b1) * t,
|
||||
),
|
||||
};
|
||||
});
|
||||
}
|
||||
|
||||
export function MapSettingsPanel({ value, onChange }: MapSettingsPanelProps) {
|
||||
const [open, setOpen] = useState(false);
|
||||
const [autoGradient, setAutoGradient] = useState(false);
|
||||
|
||||
const update = <K extends keyof MapStyleSettings>(key: K, val: MapStyleSettings[K]) => {
|
||||
onChange({ ...value, [key]: val });
|
||||
};
|
||||
|
||||
const updateDepthStop = (index: number, color: string) => {
|
||||
const next = value.depthStops.map((s, i) => (i === index ? { ...s, color } : s));
|
||||
if (autoGradient && (index === 0 || index === next.length - 1)) {
|
||||
update('depthStops', interpolateGradient(next));
|
||||
} else {
|
||||
update('depthStops', next);
|
||||
}
|
||||
};
|
||||
|
||||
const toggleAutoGradient = () => {
|
||||
const next = !autoGradient;
|
||||
setAutoGradient(next);
|
||||
if (next) {
|
||||
update('depthStops', interpolateGradient(value.depthStops));
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<>
|
||||
<button
|
||||
className={`map-settings-gear${open ? ' open' : ''}`}
|
||||
onClick={() => setOpen((p) => !p)}
|
||||
title="지도 설정"
|
||||
type="button"
|
||||
>
|
||||
⚙
|
||||
</button>
|
||||
|
||||
{open && (
|
||||
<div className="map-settings-panel">
|
||||
<div className="ms-title">지도 설정</div>
|
||||
|
||||
{/* ── Language ──────────────────────────────────── */}
|
||||
<div className="ms-section">
|
||||
<div className="ms-label">레이블 언어</div>
|
||||
<select
|
||||
value={value.labelLanguage}
|
||||
onChange={(e) => update('labelLanguage', e.target.value as MapLabelLanguage)}
|
||||
>
|
||||
{LANGUAGES.map((l) => (
|
||||
<option key={l.value} value={l.value}>
|
||||
{l.label}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
</div>
|
||||
|
||||
{/* ── Land color ────────────────────────────────── */}
|
||||
<div className="ms-section">
|
||||
<div className="ms-label">육지 색상</div>
|
||||
<div className="ms-row">
|
||||
<input
|
||||
type="color"
|
||||
className="ms-color-input"
|
||||
value={value.landColor}
|
||||
onChange={(e) => update('landColor', e.target.value)}
|
||||
/>
|
||||
<span className="ms-hex">{value.landColor}</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* ── Water color ───────────────────────────────── */}
|
||||
<div className="ms-section">
|
||||
<div className="ms-label">물 기본색</div>
|
||||
<div className="ms-row">
|
||||
<input
|
||||
type="color"
|
||||
className="ms-color-input"
|
||||
value={value.waterBaseColor}
|
||||
onChange={(e) => update('waterBaseColor', e.target.value)}
|
||||
/>
|
||||
<span className="ms-hex">{value.waterBaseColor}</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* ── Depth gradient ────────────────────────────── */}
|
||||
<div className="ms-section">
|
||||
<div className="ms-label" style={{ display: 'flex', alignItems: 'center', justifyContent: 'space-between' }}>
|
||||
수심 구간 색상
|
||||
<span
|
||||
className={`tog-btn${autoGradient ? ' on' : ''}`}
|
||||
style={{ fontSize: 8, padding: '1px 5px', marginLeft: 8 }}
|
||||
onClick={toggleAutoGradient}
|
||||
title="최소/최대 색상 기준으로 중간 구간을 자동 보간합니다"
|
||||
>
|
||||
자동채우기
|
||||
</span>
|
||||
</div>
|
||||
{value.depthStops.map((stop, i) => {
|
||||
const isEdge = i === 0 || i === value.depthStops.length - 1;
|
||||
const dimmed = autoGradient && !isEdge;
|
||||
return (
|
||||
<div className="ms-row" key={stop.depth} style={dimmed ? { opacity: 0.5 } : undefined}>
|
||||
<span className="ms-depth-label">{depthLabel(stop.depth)}</span>
|
||||
<input
|
||||
type="color"
|
||||
className="ms-color-input"
|
||||
value={stop.color}
|
||||
onChange={(e) => updateDepthStop(i, e.target.value)}
|
||||
disabled={dimmed}
|
||||
/>
|
||||
<span className="ms-hex">{stop.color}</span>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
|
||||
{/* ── Depth font size ───────────────────────────── */}
|
||||
<div className="ms-section">
|
||||
<div className="ms-label">수심 폰트 크기</div>
|
||||
<div className="tog" style={{ gap: 3 }}>
|
||||
{FONT_SIZES.map((fs) => (
|
||||
<div
|
||||
key={fs.value}
|
||||
className={`tog-btn${value.depthFontSize === fs.value ? ' on' : ''}`}
|
||||
onClick={() => update('depthFontSize', fs.value)}
|
||||
>
|
||||
{fs.label}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* ── Depth font color ──────────────────────────── */}
|
||||
<div className="ms-section">
|
||||
<div className="ms-label">수심 폰트 색상</div>
|
||||
<div className="ms-row">
|
||||
<input
|
||||
type="color"
|
||||
className="ms-color-input"
|
||||
value={value.depthFontColor}
|
||||
onChange={(e) => update('depthFontColor', e.target.value)}
|
||||
/>
|
||||
<span className="ms-hex">{value.depthFontColor}</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* ── Reset ─────────────────────────────────────── */}
|
||||
<button
|
||||
className="ms-reset"
|
||||
type="button"
|
||||
onClick={() => {
|
||||
onChange(DEFAULT_MAP_STYLE_SETTINGS);
|
||||
setAutoGradient(false);
|
||||
}}
|
||||
>
|
||||
초기화
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
</>
|
||||
);
|
||||
}
|
||||
32
apps/web/src/features/mapSettings/types.ts
Normal file
32
apps/web/src/features/mapSettings/types.ts
Normal file
@ -0,0 +1,32 @@
|
||||
export type MapLabelLanguage = 'ko' | 'en' | 'ja' | 'zh' | 'local';
|
||||
export type DepthFontSize = 'small' | 'medium' | 'large';
|
||||
|
||||
export interface DepthColorStop {
|
||||
depth: number;
|
||||
color: string;
|
||||
}
|
||||
|
||||
export interface MapStyleSettings {
|
||||
labelLanguage: MapLabelLanguage;
|
||||
landColor: string;
|
||||
waterBaseColor: string;
|
||||
depthStops: DepthColorStop[];
|
||||
depthFontSize: DepthFontSize;
|
||||
depthFontColor: string;
|
||||
}
|
||||
|
||||
export const DEFAULT_MAP_STYLE_SETTINGS: MapStyleSettings = {
|
||||
labelLanguage: 'ko',
|
||||
landColor: '#1a1a2e',
|
||||
waterBaseColor: '#14606e',
|
||||
depthStops: [
|
||||
{ depth: -8000, color: '#010610' },
|
||||
{ depth: -4000, color: '#030c1c' },
|
||||
{ depth: -2000, color: '#041022' },
|
||||
{ depth: -1000, color: '#051529' },
|
||||
{ depth: -500, color: '#061a30' },
|
||||
{ depth: -100, color: '#08263d' },
|
||||
],
|
||||
depthFontSize: 'medium',
|
||||
depthFontColor: '#e2e8f0',
|
||||
};
|
||||
@ -4,6 +4,9 @@ export type MapToggleState = {
|
||||
fcLines: boolean;
|
||||
zones: boolean;
|
||||
fleetCircles: boolean;
|
||||
predictVectors: boolean;
|
||||
shipLabels: boolean;
|
||||
subcables: boolean;
|
||||
};
|
||||
|
||||
type Props = {
|
||||
@ -16,12 +19,15 @@ export function MapToggles({ value, onToggle }: Props) {
|
||||
{ id: "pairLines", label: "쌍 연결선" },
|
||||
{ id: "pairRange", label: "쌍 연결범위" },
|
||||
{ id: "fcLines", label: "환적 연결선" },
|
||||
{ id: "zones", label: "수역 표시" },
|
||||
{ id: "fleetCircles", label: "선단 범위" },
|
||||
{ id: "zones", label: "수역 표시" },
|
||||
{ id: "predictVectors", label: "예측 벡터" },
|
||||
{ id: "shipLabels", label: "선박명 표시" },
|
||||
{ id: "subcables", label: "해저케이블" },
|
||||
];
|
||||
|
||||
return (
|
||||
<div className="tog">
|
||||
<div className="tog tog-map">
|
||||
{items.map((t) => (
|
||||
<div key={t.id} className={`tog-btn ${value[t.id] ? "on" : ""}`} onClick={() => onToggle(t.id)}>
|
||||
{t.label}
|
||||
|
||||
@ -4,11 +4,14 @@ import { Map3DSettingsToggles } from "../../features/map3dSettings/Map3DSettings
|
||||
import type { MapToggleState } from "../../features/mapToggles/MapToggles";
|
||||
import { MapToggles } from "../../features/mapToggles/MapToggles";
|
||||
import { TypeFilterGrid } from "../../features/typeFilter/TypeFilterGrid";
|
||||
import type { DerivedLegacyVessel, LegacyAlarmKind } from "../../features/legacyDashboard/model/types";
|
||||
import { LEGACY_ALARM_KIND_LABEL, LEGACY_ALARM_KINDS } from "../../features/legacyDashboard/model/types";
|
||||
import { useLegacyVessels } from "../../entities/legacyVessel/api/useLegacyVessels";
|
||||
import { buildLegacyVesselIndex } from "../../entities/legacyVessel/lib";
|
||||
import type { LegacyVesselIndex } from "../../entities/legacyVessel/lib";
|
||||
import type { LegacyVesselDataset } from "../../entities/legacyVessel/model/types";
|
||||
import { useZones } from "../../entities/zone/api/useZones";
|
||||
import { useSubcables } from "../../entities/subcable/api/useSubcables";
|
||||
import type { VesselTypeCode } from "../../entities/vessel/model/types";
|
||||
import { AisInfoPanel } from "../../widgets/aisInfo/AisInfoPanel";
|
||||
import { AisTargetList } from "../../widgets/aisTargetList/AisTargetList";
|
||||
@ -19,7 +22,12 @@ import { RelationsPanel } from "../../widgets/relations/RelationsPanel";
|
||||
import { SpeedProfilePanel } from "../../widgets/speed/SpeedProfilePanel";
|
||||
import { Topbar } from "../../widgets/topbar/Topbar";
|
||||
import { VesselInfoPanel } from "../../widgets/info/VesselInfoPanel";
|
||||
import { SubcableInfoPanel } from "../../widgets/subcableInfo/SubcableInfoPanel";
|
||||
import { VesselList } from "../../widgets/vesselList/VesselList";
|
||||
import { MapSettingsPanel } from "../../features/mapSettings/MapSettingsPanel";
|
||||
import { DepthLegend } from "../../widgets/legend/DepthLegend";
|
||||
import { DEFAULT_MAP_STYLE_SETTINGS } from "../../features/mapSettings/types";
|
||||
import type { MapStyleSettings } from "../../features/mapSettings/types";
|
||||
import {
|
||||
buildLegacyHitMap,
|
||||
computeCountsByType,
|
||||
@ -33,6 +41,11 @@ import {
|
||||
import { VESSEL_TYPE_ORDER } from "../../entities/vessel/model/meta";
|
||||
|
||||
const AIS_API_BASE = (import.meta.env.VITE_API_URL || "/snp-api").replace(/\/$/, "");
|
||||
const AIS_CENTER = {
|
||||
lon: 126.95,
|
||||
lat: 35.95,
|
||||
radiusMeters: 2_000_000,
|
||||
};
|
||||
|
||||
function fmtLocal(iso: string | null) {
|
||||
if (!iso) return "-";
|
||||
@ -42,6 +55,7 @@ function fmtLocal(iso: string | null) {
|
||||
}
|
||||
|
||||
type Bbox = [number, number, number, number]; // [lonMin, latMin, lonMax, latMax]
|
||||
type FleetRelationSortMode = "count" | "range";
|
||||
|
||||
function inBbox(lon: number, lat: number, bbox: Bbox) {
|
||||
const [lonMin, latMin, lonMax, latMax] = bbox;
|
||||
@ -62,6 +76,7 @@ function useLegacyIndex(data: LegacyVesselDataset | null): LegacyVesselIndex | n
|
||||
export function DashboardPage() {
|
||||
const { data: zones, error: zonesError } = useZones();
|
||||
const { data: legacyData, error: legacyError } = useLegacyVessels();
|
||||
const { data: subcableData } = useSubcables();
|
||||
const legacyIndex = useLegacyIndex(legacyData);
|
||||
|
||||
const [viewBbox, setViewBbox] = useState<Bbox | null>(null);
|
||||
@ -71,13 +86,22 @@ export function DashboardPage() {
|
||||
|
||||
const { targets, snapshot } = useAisTargetPolling({
|
||||
initialMinutes: 60,
|
||||
bootstrapMinutes: 10,
|
||||
incrementalMinutes: 2,
|
||||
intervalMs: 60_000,
|
||||
retentionMinutes: 90,
|
||||
bbox: useApiBbox ? apiBbox : undefined,
|
||||
centerLon: useApiBbox ? undefined : AIS_CENTER.lon,
|
||||
centerLat: useApiBbox ? undefined : AIS_CENTER.lat,
|
||||
radiusMeters: useApiBbox ? undefined : AIS_CENTER.radiusMeters,
|
||||
});
|
||||
|
||||
const [selectedMmsi, setSelectedMmsi] = useState<number | null>(null);
|
||||
const [highlightedMmsiSet, setHighlightedMmsiSet] = useState<number[]>([]);
|
||||
const [hoveredMmsiSet, setHoveredMmsiSet] = useState<number[]>([]);
|
||||
const [hoveredFleetMmsiSet, setHoveredFleetMmsiSet] = useState<number[]>([]);
|
||||
const [hoveredPairMmsiSet, setHoveredPairMmsiSet] = useState<number[]>([]);
|
||||
const [hoveredFleetOwnerKey, setHoveredFleetOwnerKey] = useState<string | null>(null);
|
||||
const [typeEnabled, setTypeEnabled] = useState<Record<VesselTypeCode, boolean>>({
|
||||
PT: true,
|
||||
"PT-S": true,
|
||||
@ -89,16 +113,32 @@ export function DashboardPage() {
|
||||
const [showTargets, setShowTargets] = useState(true);
|
||||
const [showOthers, setShowOthers] = useState(false);
|
||||
|
||||
const [baseMap, setBaseMap] = useState<BaseMapId>("enhanced");
|
||||
// 레거시 베이스맵 비활성 — 향후 위성/라이트 등 추가 시 재활용
|
||||
// eslint-disable-next-line @typescript-eslint/no-unused-vars
|
||||
const [baseMap, _setBaseMap] = useState<BaseMapId>("enhanced");
|
||||
const [projection, setProjection] = useState<MapProjectionId>("mercator");
|
||||
const [mapStyleSettings, setMapStyleSettings] = useState<MapStyleSettings>(DEFAULT_MAP_STYLE_SETTINGS);
|
||||
|
||||
const [overlays, setOverlays] = useState<MapToggleState>({
|
||||
pairLines: true,
|
||||
pairRange: false,
|
||||
pairRange: true,
|
||||
fcLines: true,
|
||||
zones: true,
|
||||
fleetCircles: true,
|
||||
predictVectors: true,
|
||||
shipLabels: true,
|
||||
subcables: false,
|
||||
});
|
||||
const [fleetRelationSortMode, setFleetRelationSortMode] = useState<FleetRelationSortMode>("count");
|
||||
|
||||
const [alarmKindEnabled, setAlarmKindEnabled] = useState<Record<LegacyAlarmKind, boolean>>(() => {
|
||||
return Object.fromEntries(LEGACY_ALARM_KINDS.map((k) => [k, true])) as Record<LegacyAlarmKind, boolean>;
|
||||
});
|
||||
|
||||
const [fleetFocus, setFleetFocus] = useState<{ id: string | number; center: [number, number]; zoom?: number } | undefined>(undefined);
|
||||
|
||||
const [hoveredCableId, setHoveredCableId] = useState<string | null>(null);
|
||||
const [selectedCableId, setSelectedCableId] = useState<string | null>(null);
|
||||
|
||||
const [settings, setSettings] = useState<Map3DSettings>({
|
||||
showShips: true,
|
||||
@ -106,6 +146,8 @@ export function DashboardPage() {
|
||||
showSeamark: false,
|
||||
});
|
||||
|
||||
const [isProjectionLoading, setIsProjectionLoading] = useState(false);
|
||||
|
||||
const [clock, setClock] = useState(() => new Date().toLocaleString("ko-KR", { hour12: false }));
|
||||
useEffect(() => {
|
||||
const id = window.setInterval(() => setClock(new Date().toLocaleString("ko-KR", { hour12: false })), 1000);
|
||||
@ -162,6 +204,26 @@ export function DashboardPage() {
|
||||
const fcLinksAll = useMemo(() => computeFcLinks(legacyVesselsAll), [legacyVesselsAll]);
|
||||
const alarms = useMemo(() => computeLegacyAlarms({ vessels: legacyVesselsAll, pairLinks: pairLinksAll, fcLinks: fcLinksAll }), [legacyVesselsAll, pairLinksAll, fcLinksAll]);
|
||||
|
||||
const alarmKindCounts = useMemo(() => {
|
||||
const base = Object.fromEntries(LEGACY_ALARM_KINDS.map((k) => [k, 0])) as Record<LegacyAlarmKind, number>;
|
||||
for (const a of alarms) {
|
||||
base[a.kind] = (base[a.kind] ?? 0) + 1;
|
||||
}
|
||||
return base;
|
||||
}, [alarms]);
|
||||
|
||||
const enabledAlarmKinds = useMemo(() => {
|
||||
return LEGACY_ALARM_KINDS.filter((k) => alarmKindEnabled[k]);
|
||||
}, [alarmKindEnabled]);
|
||||
|
||||
const allAlarmKindsEnabled = enabledAlarmKinds.length === LEGACY_ALARM_KINDS.length;
|
||||
|
||||
const filteredAlarms = useMemo(() => {
|
||||
if (allAlarmKindsEnabled) return alarms;
|
||||
const enabled = new Set(enabledAlarmKinds);
|
||||
return alarms.filter((a) => enabled.has(a.kind));
|
||||
}, [alarms, enabledAlarmKinds, allAlarmKindsEnabled]);
|
||||
|
||||
const pairLinksForMap = useMemo(() => computePairLinks(legacyVesselsFiltered), [legacyVesselsFiltered]);
|
||||
const fcLinksForMap = useMemo(() => computeFcLinks(legacyVesselsFiltered), [legacyVesselsFiltered]);
|
||||
const fleetCirclesForMap = useMemo(() => computeFleetCircles(legacyVesselsFiltered), [legacyVesselsFiltered]);
|
||||
@ -181,11 +243,59 @@ export function DashboardPage() {
|
||||
return legacyHits.get(selectedMmsi) ?? null;
|
||||
}, [selectedMmsi, legacyHits]);
|
||||
|
||||
const availableTargetMmsiSet = useMemo(
|
||||
() => new Set(targetsInScope.map((t) => t.mmsi).filter((mmsi) => Number.isFinite(mmsi))),
|
||||
[targetsInScope],
|
||||
);
|
||||
const activeHighlightedMmsiSet = useMemo(
|
||||
() => highlightedMmsiSet.filter((mmsi) => availableTargetMmsiSet.has(mmsi)),
|
||||
[highlightedMmsiSet, availableTargetMmsiSet],
|
||||
);
|
||||
|
||||
const setUniqueSorted = (items: number[]) =>
|
||||
Array.from(new Set(items.filter((item) => Number.isFinite(item)))).sort((a, b) => a - b);
|
||||
|
||||
const setSortedIfChanged = (next: number[]) => {
|
||||
const sorted = setUniqueSorted(next);
|
||||
return (prev: number[]) => (prev.length === sorted.length && prev.every((v, i) => v === sorted[i]) ? prev : sorted);
|
||||
};
|
||||
|
||||
const handleFleetContextMenu = (ownerKey: string, mmsis: number[]) => {
|
||||
if (!mmsis.length) return;
|
||||
const members = mmsis
|
||||
.map((mmsi) => legacyVesselsFiltered.find((v): v is DerivedLegacyVessel => v.mmsi === mmsi))
|
||||
.filter(
|
||||
(v): v is DerivedLegacyVessel & { lat: number; lon: number } =>
|
||||
v != null && typeof v.lat === "number" && typeof v.lon === "number" && Number.isFinite(v.lat) && Number.isFinite(v.lon),
|
||||
);
|
||||
|
||||
if (members.length === 0) return;
|
||||
const sumLon = members.reduce((acc, v) => acc + v.lon, 0);
|
||||
const sumLat = members.reduce((acc, v) => acc + v.lat, 0);
|
||||
const center: [number, number] = [sumLon / members.length, sumLat / members.length];
|
||||
setFleetFocus({
|
||||
id: `${ownerKey}-${Date.now()}`,
|
||||
center,
|
||||
zoom: 9,
|
||||
});
|
||||
};
|
||||
|
||||
const toggleHighlightedMmsi = (mmsi: number) => {
|
||||
setHighlightedMmsiSet((prev) => {
|
||||
const next = new Set(prev);
|
||||
if (next.has(mmsi)) next.delete(mmsi);
|
||||
else next.add(mmsi);
|
||||
return Array.from(next).sort((a, b) => a - b);
|
||||
});
|
||||
};
|
||||
|
||||
const fishingCount = legacyVesselsAll.filter((v) => v.state.isFishing).length;
|
||||
const transitCount = legacyVesselsAll.filter((v) => v.state.isTransit).length;
|
||||
|
||||
const enabledTypes = useMemo(() => VESSEL_TYPE_ORDER.filter((c) => typeEnabled[c]), [typeEnabled]);
|
||||
const speedPanelType = (selectedLegacyVessel?.shipCode ?? (enabledTypes.length === 1 ? enabledTypes[0] : "PT")) as VesselTypeCode;
|
||||
const enabledAlarmKindCount = enabledAlarmKinds.length;
|
||||
const alarmFilterSummary = allAlarmKindsEnabled ? "전체" : `${enabledAlarmKindCount}/${LEGACY_ALARM_KINDS.length}`;
|
||||
|
||||
return (
|
||||
<div className="app">
|
||||
@ -249,30 +359,28 @@ export function DashboardPage() {
|
||||
</div>
|
||||
|
||||
<div className="sb">
|
||||
<div className="sb-t">지도 표시 설정</div>
|
||||
<MapToggles value={overlays} onToggle={(k) => setOverlays((prev) => ({ ...prev, [k]: !prev[k] }))} />
|
||||
<div style={{ fontSize: 9, fontWeight: 700, color: "var(--muted)", letterSpacing: 1.5, marginTop: 8, marginBottom: 6 }}>
|
||||
베이스맵
|
||||
<div className="sb-t" style={{ display: "flex", alignItems: "center" }}>
|
||||
지도 표시 설정
|
||||
<div style={{ flex: 1 }} />
|
||||
<div
|
||||
className={`tog-btn ${projection === "globe" ? "on" : ""}`}
|
||||
onClick={() => setProjection((p) => (p === "globe" ? "mercator" : "globe"))}
|
||||
title="3D 지구본 투영: 드래그로 회전, 휠로 확대/축소"
|
||||
style={{ fontSize: 9, padding: "2px 8px" }}
|
||||
>
|
||||
3D
|
||||
</div>
|
||||
</div>
|
||||
<div className="tog" style={{ flexWrap: "nowrap", alignItems: "center" }}>
|
||||
<MapToggles value={overlays} onToggle={(k) => setOverlays((prev) => ({ ...prev, [k]: !prev[k] }))} />
|
||||
{/* 베이스맵 선택 — 현재 enhanced 단일 맵 사용, 레거시는 비활성
|
||||
<div className="tog" style={{ flexWrap: "nowrap", alignItems: "center", marginTop: 8 }}>
|
||||
<div className={`tog-btn ${baseMap === "enhanced" ? "on" : ""}`} onClick={() => setBaseMap("enhanced")} title="현재 지도(해저지형/3D 표현 포함)">
|
||||
기본
|
||||
</div>
|
||||
<div className={`tog-btn ${baseMap === "legacy" ? "on" : ""}`} onClick={() => setBaseMap("legacy")} title="레거시 대시보드(Carto Dark) 베이스맵">
|
||||
레거시
|
||||
</div>
|
||||
|
||||
<div style={{ flex: 1 }} />
|
||||
|
||||
<div
|
||||
className={`tog-btn ${projection === "globe" ? "on" : ""}`}
|
||||
onClick={() => setProjection((p) => (p === "globe" ? "mercator" : "globe"))}
|
||||
title="지구본(globe) 투영: 드래그로 회전, 휠로 확대/축소"
|
||||
>
|
||||
지구본
|
||||
</div>
|
||||
</div>
|
||||
<div style={{ fontSize: 10, color: "var(--muted)" }}>지도 우하단 Attribution(라이센스) 표기 유지</div>
|
||||
</div> */}
|
||||
</div>
|
||||
|
||||
<div className="sb">
|
||||
@ -281,18 +389,57 @@ export function DashboardPage() {
|
||||
</div>
|
||||
|
||||
<div className="sb" style={{ maxHeight: 260, display: "flex", flexDirection: "column", overflow: "hidden" }}>
|
||||
<div className="sb-t">
|
||||
선단 연관관계{" "}
|
||||
<span style={{ color: "var(--accent)", fontSize: 8 }}>
|
||||
{selectedLegacyVessel ? `${selectedLegacyVessel.permitNo} 연관` : "(선박 클릭 시 표시)"}
|
||||
</span>
|
||||
<div className="sb-t sb-t-row">
|
||||
<div>
|
||||
선단 연관관계{" "}
|
||||
<span style={{ color: "var(--accent)", fontSize: 8 }}>
|
||||
{selectedLegacyVessel ? `${selectedLegacyVessel.permitNo} 연관` : "(선박 클릭 시 표시)"}
|
||||
</span>
|
||||
</div>
|
||||
<div className="relation-sort">
|
||||
<label className="relation-sort__option">
|
||||
<input
|
||||
type="radio"
|
||||
name="fleet-relation-sort"
|
||||
checked={fleetRelationSortMode === "count"}
|
||||
onChange={() => setFleetRelationSortMode("count")}
|
||||
/>
|
||||
척수
|
||||
</label>
|
||||
<label className="relation-sort__option">
|
||||
<input
|
||||
type="radio"
|
||||
name="fleet-relation-sort"
|
||||
checked={fleetRelationSortMode === "range"}
|
||||
onChange={() => setFleetRelationSortMode("range")}
|
||||
/>
|
||||
범위
|
||||
</label>
|
||||
</div>
|
||||
</div>
|
||||
<div style={{ overflowY: "auto", minHeight: 0 }}>
|
||||
<RelationsPanel
|
||||
selectedVessel={selectedLegacyVessel}
|
||||
vessels={legacyVesselsAll}
|
||||
fleetVessels={legacyVesselsFiltered}
|
||||
<RelationsPanel
|
||||
selectedVessel={selectedLegacyVessel}
|
||||
vessels={legacyVesselsAll}
|
||||
fleetVessels={legacyVesselsFiltered}
|
||||
onSelectMmsi={setSelectedMmsi}
|
||||
onToggleHighlightMmsi={toggleHighlightedMmsi}
|
||||
onHoverMmsi={(mmsis) => setHoveredMmsiSet(setUniqueSorted(mmsis))}
|
||||
onClearHover={() => setHoveredMmsiSet([])}
|
||||
onHoverPair={(pairMmsis) => setHoveredPairMmsiSet(setUniqueSorted(pairMmsis))}
|
||||
onClearPairHover={() => setHoveredPairMmsiSet([])}
|
||||
onHoverFleet={(ownerKey, fleetMmsis) => {
|
||||
setHoveredFleetOwnerKey(ownerKey);
|
||||
setHoveredFleetMmsiSet(setSortedIfChanged(fleetMmsis));
|
||||
}}
|
||||
onClearFleetHover={() => {
|
||||
setHoveredFleetOwnerKey(null);
|
||||
setHoveredFleetMmsiSet((prev) => (prev.length === 0 ? prev : []));
|
||||
}}
|
||||
fleetSortMode={fleetRelationSortMode}
|
||||
hoveredFleetOwnerKey={hoveredFleetOwnerKey}
|
||||
hoveredFleetMmsiSet={hoveredFleetMmsiSet}
|
||||
onContextMenuFleet={handleFleetContextMenu}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
@ -304,12 +451,80 @@ export function DashboardPage() {
|
||||
({legacyVesselsFiltered.length}척)
|
||||
</span>
|
||||
</div>
|
||||
<VesselList vessels={legacyVesselsFiltered} selectedMmsi={selectedMmsi} onSelectMmsi={setSelectedMmsi} />
|
||||
<VesselList
|
||||
vessels={legacyVesselsFiltered}
|
||||
selectedMmsi={selectedMmsi}
|
||||
highlightedMmsiSet={activeHighlightedMmsiSet}
|
||||
onSelectMmsi={setSelectedMmsi}
|
||||
onToggleHighlightMmsi={toggleHighlightedMmsi}
|
||||
onHoverMmsi={(mmsi) => setHoveredMmsiSet([mmsi])}
|
||||
onClearHover={() => setHoveredMmsiSet([])}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="sb" style={{ maxHeight: 130, overflowY: "auto" }}>
|
||||
<div className="sb-t">실시간 경고</div>
|
||||
<AlarmsPanel alarms={alarms} onSelectMmsi={setSelectedMmsi} />
|
||||
<div className="sb" style={{ maxHeight: 130, display: "flex", flexDirection: "column", overflow: "visible" }}>
|
||||
<div className="sb-t sb-t-row" style={{ marginBottom: 6 }}>
|
||||
<div>
|
||||
실시간 경고{" "}
|
||||
<span style={{ color: "var(--accent)", fontSize: 8 }}>
|
||||
({filteredAlarms.length}/{alarms.length})
|
||||
</span>
|
||||
</div>
|
||||
|
||||
{LEGACY_ALARM_KINDS.length <= 3 ? (
|
||||
<div style={{ display: "flex", gap: 6, alignItems: "center" }}>
|
||||
{LEGACY_ALARM_KINDS.map((k) => (
|
||||
<label key={k} style={{ display: "inline-flex", gap: 4, alignItems: "center", cursor: "pointer", userSelect: "none" }}>
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={!!alarmKindEnabled[k]}
|
||||
onChange={() => setAlarmKindEnabled((prev) => ({ ...prev, [k]: !prev[k] }))}
|
||||
/>
|
||||
<span style={{ fontSize: 8, color: "var(--muted)", whiteSpace: "nowrap" }}>{LEGACY_ALARM_KIND_LABEL[k]}</span>
|
||||
</label>
|
||||
))}
|
||||
</div>
|
||||
) : (
|
||||
<details className="alarm-filter">
|
||||
<summary className="alarm-filter__summary" title="경고 종류 필터">
|
||||
{alarmFilterSummary}
|
||||
</summary>
|
||||
<div className="alarm-filter__menu" role="menu" aria-label="alarm kind filter">
|
||||
<label className="alarm-filter__row">
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={allAlarmKindsEnabled}
|
||||
onChange={() =>
|
||||
setAlarmKindEnabled((prev) => {
|
||||
const allOn = LEGACY_ALARM_KINDS.every((k) => prev[k]);
|
||||
const nextVal = allOn ? false : true;
|
||||
return Object.fromEntries(LEGACY_ALARM_KINDS.map((k) => [k, nextVal])) as Record<LegacyAlarmKind, boolean>;
|
||||
})
|
||||
}
|
||||
/>
|
||||
전체
|
||||
<span className="alarm-filter__cnt">{alarms.length}</span>
|
||||
</label>
|
||||
<div className="alarm-filter__sep" />
|
||||
{LEGACY_ALARM_KINDS.map((k) => (
|
||||
<label key={k} className="alarm-filter__row">
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={!!alarmKindEnabled[k]}
|
||||
onChange={() => setAlarmKindEnabled((prev) => ({ ...prev, [k]: !prev[k] }))}
|
||||
/>
|
||||
{LEGACY_ALARM_KIND_LABEL[k]}
|
||||
<span className="alarm-filter__cnt">{alarmKindCounts[k] ?? 0}</span>
|
||||
</label>
|
||||
))}
|
||||
</div>
|
||||
</details>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div style={{ overflowY: "auto", minHeight: 0, flex: 1 }}>
|
||||
<AlarmsPanel alarms={filteredAlarms} onSelectMmsi={setSelectedMmsi} />
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{adminMode ? (
|
||||
@ -463,27 +678,72 @@ export function DashboardPage() {
|
||||
</div>
|
||||
|
||||
<div className="map-area">
|
||||
{isProjectionLoading ? (
|
||||
<div className="map-loader-overlay" role="status" aria-live="polite">
|
||||
<div className="map-loader-overlay__panel">
|
||||
<div className="map-loader-overlay__spinner" />
|
||||
<div className="map-loader-overlay__text">지도 모드 동기화 중...</div>
|
||||
<div className="map-loader-overlay__bar">
|
||||
<div className="map-loader-overlay__fill" />
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
) : null}
|
||||
<Map3D
|
||||
targets={targetsForMap}
|
||||
zones={zones}
|
||||
selectedMmsi={selectedMmsi}
|
||||
highlightedMmsiSet={activeHighlightedMmsiSet}
|
||||
hoveredMmsiSet={hoveredMmsiSet}
|
||||
hoveredFleetMmsiSet={hoveredFleetMmsiSet}
|
||||
hoveredPairMmsiSet={hoveredPairMmsiSet}
|
||||
hoveredFleetOwnerKey={hoveredFleetOwnerKey}
|
||||
settings={settings}
|
||||
baseMap={baseMap}
|
||||
projection={projection}
|
||||
overlays={overlays}
|
||||
onSelectMmsi={setSelectedMmsi}
|
||||
onToggleHighlightMmsi={toggleHighlightedMmsi}
|
||||
onViewBboxChange={setViewBbox}
|
||||
legacyHits={legacyHits}
|
||||
pairLinks={pairLinksForMap}
|
||||
fcLinks={fcLinksForMap}
|
||||
fleetCircles={fleetCirclesForMap}
|
||||
fleetFocus={fleetFocus}
|
||||
onProjectionLoadingChange={setIsProjectionLoading}
|
||||
onHoverMmsi={(mmsis) => setHoveredMmsiSet(setUniqueSorted(mmsis))}
|
||||
onClearMmsiHover={() => setHoveredMmsiSet((prev) => (prev.length === 0 ? prev : []))}
|
||||
onHoverPair={(pairMmsis) => setHoveredPairMmsiSet(setUniqueSorted(pairMmsis))}
|
||||
onClearPairHover={() => setHoveredPairMmsiSet([])}
|
||||
onHoverFleet={(ownerKey, fleetMmsis) => {
|
||||
setHoveredFleetOwnerKey(ownerKey);
|
||||
setHoveredFleetMmsiSet(setSortedIfChanged(fleetMmsis));
|
||||
}}
|
||||
onClearFleetHover={() => {
|
||||
setHoveredFleetOwnerKey(null);
|
||||
setHoveredFleetMmsiSet((prev) => (prev.length === 0 ? prev : []));
|
||||
}}
|
||||
subcableGeo={subcableData?.geo ?? null}
|
||||
hoveredCableId={hoveredCableId}
|
||||
onHoverCable={setHoveredCableId}
|
||||
onClickCable={(id) => setSelectedCableId((prev) => (prev === id ? null : id))}
|
||||
mapStyleSettings={mapStyleSettings}
|
||||
/>
|
||||
<MapSettingsPanel value={mapStyleSettings} onChange={setMapStyleSettings} />
|
||||
<DepthLegend depthStops={mapStyleSettings.depthStops} />
|
||||
<MapLegend />
|
||||
{selectedLegacyVessel ? (
|
||||
<VesselInfoPanel vessel={selectedLegacyVessel} allVessels={legacyVesselsAll} onClose={() => setSelectedMmsi(null)} onSelectMmsi={setSelectedMmsi} />
|
||||
) : selectedTarget ? (
|
||||
<AisInfoPanel target={selectedTarget} legacy={selectedLegacyInfo} onClose={() => setSelectedMmsi(null)} />
|
||||
) : null}
|
||||
{selectedCableId && subcableData?.details.get(selectedCableId) ? (
|
||||
<SubcableInfoPanel
|
||||
detail={subcableData.details.get(selectedCableId)!}
|
||||
color={subcableData.geo.features.find((f) => f.properties.id === selectedCableId)?.properties.color}
|
||||
onClose={() => setSelectedCableId(null)}
|
||||
/>
|
||||
) : null}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
|
||||
58
apps/web/src/shared/lib/map/palette.ts
Normal file
58
apps/web/src/shared/lib/map/palette.ts
Normal file
@ -0,0 +1,58 @@
|
||||
export type Rgb = [number, number, number];
|
||||
export type DeckRgba = [number, number, number, number];
|
||||
|
||||
export function rgbToHex(rgb: Rgb): string {
|
||||
const toHex = (v: number) => {
|
||||
const clamped = Math.max(0, Math.min(255, Math.round(v)));
|
||||
return clamped.toString(16).padStart(2, "0");
|
||||
};
|
||||
return `#${toHex(rgb[0])}${toHex(rgb[1])}${toHex(rgb[2])}`;
|
||||
}
|
||||
|
||||
export function rgba(rgb: Rgb, alpha01: number): string {
|
||||
const a = Math.max(0, Math.min(1, alpha01));
|
||||
return `rgba(${rgb[0]},${rgb[1]},${rgb[2]},${a})`;
|
||||
}
|
||||
|
||||
export function deckRgba(rgb: Rgb, alpha255: number): DeckRgba {
|
||||
const a = Math.max(0, Math.min(255, Math.round(alpha255)));
|
||||
return [rgb[0], rgb[1], rgb[2], a];
|
||||
}
|
||||
|
||||
// CN-permit (legacy) ship category colors. Used by both map layers and legend.
|
||||
export const LEGACY_CODE_COLORS_RGB: Record<string, Rgb> = {
|
||||
PT: [30, 64, 175], // #1e40af
|
||||
"PT-S": [234, 88, 12], // #ea580c
|
||||
GN: [16, 185, 129], // #10b981
|
||||
OT: [139, 92, 246], // #8b5cf6
|
||||
PS: [239, 68, 68], // #ef4444
|
||||
FC: [245, 158, 11], // #f59e0b
|
||||
C21: [236, 72, 153], // #ec4899
|
||||
};
|
||||
|
||||
export const LEGACY_CODE_COLORS_HEX: Record<string, string> = Object.fromEntries(
|
||||
Object.entries(LEGACY_CODE_COLORS_RGB).map(([k, rgb]) => [k, rgbToHex(rgb)]),
|
||||
) as Record<string, string>;
|
||||
|
||||
// Non-target AIS ships should be visible but muted (speed encoded mainly via brightness).
|
||||
export const OTHER_AIS_SPEED_RGB = {
|
||||
fast: [148, 163, 184] as Rgb, // SOG >= 10
|
||||
moving: [100, 116, 139] as Rgb, // 1 <= SOG < 10
|
||||
stopped: [71, 85, 105] as Rgb, // SOG < 1
|
||||
};
|
||||
|
||||
export const OTHER_AIS_SPEED_HEX = {
|
||||
fast: rgbToHex(OTHER_AIS_SPEED_RGB.fast),
|
||||
moving: rgbToHex(OTHER_AIS_SPEED_RGB.moving),
|
||||
stopped: rgbToHex(OTHER_AIS_SPEED_RGB.stopped),
|
||||
};
|
||||
|
||||
// Overlay palette: keep a cohesive "warm alert" family, but ensure each overlay type is distinguishable.
|
||||
export const OVERLAY_RGB = {
|
||||
pairNormal: [59, 130, 246] as Rgb, // blue-500
|
||||
pairWarn: [251, 113, 133] as Rgb, // rose-400 (쌍 이격 경고)
|
||||
fcTransfer: [249, 115, 22] as Rgb, // orange-500 (환적 연결)
|
||||
fleetRange: [250, 204, 21] as Rgb, // yellow-400 (선단 범위)
|
||||
suspicious: [239, 68, 68] as Rgb, // red-500
|
||||
};
|
||||
|
||||
@ -1,13 +1,15 @@
|
||||
import type { LegacyAlarm } from "../../features/legacyDashboard/model/types";
|
||||
|
||||
export function AlarmsPanel({ alarms, onSelectMmsi }: { alarms: LegacyAlarm[]; onSelectMmsi?: (mmsi: number) => void }) {
|
||||
const shown = alarms.slice(0, 6);
|
||||
if (alarms.length === 0) {
|
||||
return <div style={{ fontSize: 11, color: "var(--muted)" }}>(현재 경고 없음)</div>;
|
||||
}
|
||||
|
||||
return (
|
||||
<div>
|
||||
{shown.map((a, idx) => (
|
||||
{alarms.map((a, idx) => (
|
||||
<div
|
||||
key={`${a.kind}-${idx}`}
|
||||
key={`${a.kind}-${a.relatedMmsi.join("-")}-${a.timeLabel}-${idx}`}
|
||||
className={`ai ${a.severity}`}
|
||||
onClick={() => {
|
||||
if (!onSelectMmsi) return;
|
||||
|
||||
@ -2,6 +2,7 @@ import { ZONE_META } from "../../entities/zone/model/meta";
|
||||
import { SPEED_MAX, VESSEL_TYPES } from "../../entities/vessel/model/meta";
|
||||
import type { DerivedLegacyVessel } from "../../features/legacyDashboard/model/types";
|
||||
import { haversineNm } from "../../shared/lib/geo/haversineNm";
|
||||
import { OVERLAY_RGB, rgbToHex } from "../../shared/lib/map/palette";
|
||||
|
||||
type Props = {
|
||||
vessel: DerivedLegacyVessel;
|
||||
@ -93,7 +94,7 @@ export function VesselInfoPanel({ vessel: v, allVessels, onClose, onSelectMmsi }
|
||||
</div>
|
||||
<div className="ir">
|
||||
<span className="il">쌍 이격</span>
|
||||
<span className="iv" style={{ color: pairDist > 3 ? "#F59E0B" : "#22C55E" }}>
|
||||
<span className="iv" style={{ color: pairDist > 3 ? rgbToHex(OVERLAY_RGB.pairWarn) : "#22C55E" }}>
|
||||
{pairDist.toFixed(2)}NM {pairDist > 3 ? "⚠" : "✓"}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
33
apps/web/src/widgets/legend/DepthLegend.tsx
Normal file
33
apps/web/src/widgets/legend/DepthLegend.tsx
Normal file
@ -0,0 +1,33 @@
|
||||
import { useMemo } from 'react';
|
||||
import type { DepthColorStop } from '../../features/mapSettings/types';
|
||||
|
||||
interface DepthLegendProps {
|
||||
depthStops: DepthColorStop[];
|
||||
}
|
||||
|
||||
export function DepthLegend({ depthStops }: DepthLegendProps) {
|
||||
const sorted = useMemo(
|
||||
() => [...depthStops].sort((a, b) => a.depth - b.depth),
|
||||
[depthStops],
|
||||
);
|
||||
|
||||
const gradient = useMemo(() => {
|
||||
if (sorted.length === 0) return 'transparent';
|
||||
const stops = sorted.map((s, i) => {
|
||||
const pct = (i / (sorted.length - 1)) * 100;
|
||||
return `${s.color} ${pct.toFixed(0)}%`;
|
||||
});
|
||||
return `linear-gradient(to bottom, ${stops.join(', ')})`;
|
||||
}, [sorted]);
|
||||
|
||||
return (
|
||||
<div className="depth-legend">
|
||||
<div className="depth-legend__bar" style={{ background: gradient }} />
|
||||
<div className="depth-legend__ticks">
|
||||
{sorted.map((s) => (
|
||||
<span key={s.depth}>{Math.abs(s.depth).toLocaleString()}m</span>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@ -1,4 +1,5 @@
|
||||
import { ZONE_IDS, ZONE_META } from "../../entities/zone/model/meta";
|
||||
import { LEGACY_CODE_COLORS_HEX, OTHER_AIS_SPEED_HEX, OVERLAY_RGB, rgbToHex, rgba } from "../../shared/lib/map/palette";
|
||||
|
||||
export function MapLegend() {
|
||||
return (
|
||||
@ -12,48 +13,56 @@ export function MapLegend() {
|
||||
))}
|
||||
|
||||
<div className="lt" style={{ marginTop: 8 }}>
|
||||
AIS 선박(속도)
|
||||
기타 AIS 선박(속도)
|
||||
</div>
|
||||
<div className="li">
|
||||
<div className="ls" style={{ background: "#3B82F6", borderRadius: 999 }} />
|
||||
<div className="ls" style={{ background: OTHER_AIS_SPEED_HEX.fast, borderRadius: 999 }} />
|
||||
SOG ≥ 10 kt
|
||||
</div>
|
||||
<div className="li">
|
||||
<div className="ls" style={{ background: "#22C55E", borderRadius: 999 }} />
|
||||
<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: "#64748B", borderRadius: 999 }} />
|
||||
SOG < 1 kt (or unknown)
|
||||
<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="li">
|
||||
<div className="ls" style={{ background: "#1E40AF", borderRadius: 999 }} />
|
||||
<div className="ls" style={{ background: LEGACY_CODE_COLORS_HEX.PT, borderRadius: 999 }} />
|
||||
PT 본선 (ring + 색상)
|
||||
</div>
|
||||
<div className="li">
|
||||
<div className="ls" style={{ background: "#EA580C", borderRadius: 999 }} />
|
||||
<div className="ls" style={{ background: LEGACY_CODE_COLORS_HEX["PT-S"], borderRadius: 999 }} />
|
||||
PT-S 부속선
|
||||
</div>
|
||||
<div className="li">
|
||||
<div className="ls" style={{ background: "#10B981", borderRadius: 999 }} />
|
||||
<div className="ls" style={{ background: LEGACY_CODE_COLORS_HEX.GN, borderRadius: 999 }} />
|
||||
GN 유망
|
||||
</div>
|
||||
<div className="li">
|
||||
<div className="ls" style={{ background: "#8B5CF6", borderRadius: 999 }} />
|
||||
<div className="ls" style={{ background: LEGACY_CODE_COLORS_HEX.OT, borderRadius: 999 }} />
|
||||
OT 1척식
|
||||
</div>
|
||||
<div className="li">
|
||||
<div className="ls" style={{ background: "#EF4444", borderRadius: 999 }} />
|
||||
<div className="ls" style={{ background: LEGACY_CODE_COLORS_HEX.PS, borderRadius: 999 }} />
|
||||
PS 위망
|
||||
</div>
|
||||
<div className="li">
|
||||
<div className="ls" style={{ background: "#F59E0B", borderRadius: 999 }} />
|
||||
<div className="ls" style={{ background: LEGACY_CODE_COLORS_HEX.FC, borderRadius: 999 }} />
|
||||
FC 운반선
|
||||
</div>
|
||||
<div className="li">
|
||||
<div className="ls" style={{ background: LEGACY_CODE_COLORS_HEX.C21, borderRadius: 999 }} />
|
||||
C21
|
||||
</div>
|
||||
|
||||
<div className="lt" style={{ marginTop: 8 }}>
|
||||
밀도(3D)
|
||||
@ -66,25 +75,40 @@ export function MapLegend() {
|
||||
연결선
|
||||
</div>
|
||||
<div className="li">
|
||||
<div style={{ width: 20, height: 2, background: "rgba(59,130,246,.35)", borderRadius: 1 }} />
|
||||
<div style={{ width: 20, height: 2, background: rgba(OVERLAY_RGB.pairNormal, 0.35), borderRadius: 1 }} />
|
||||
PT↔PT-S 쌍 (정상)
|
||||
</div>
|
||||
<div className="li">
|
||||
<div style={{ width: 14, height: 14, borderRadius: "50%", border: "1px solid rgba(59,130,246,.6)" }} />
|
||||
<div style={{ width: 14, height: 14, borderRadius: "50%", border: `1px solid ${rgba(OVERLAY_RGB.pairNormal, 0.6)}` }} />
|
||||
쌍 연결범위
|
||||
</div>
|
||||
<div className="li">
|
||||
<div style={{ width: 20, height: 2, background: "#F59E0B", borderRadius: 1 }} />
|
||||
<div style={{ width: 20, height: 2, background: rgbToHex(OVERLAY_RGB.pairWarn), borderRadius: 1 }} />
|
||||
쌍 이격 경고 (>3NM)
|
||||
</div>
|
||||
<div className="li">
|
||||
<div style={{ width: 20, height: 2, background: "#D97706", borderRadius: 1 }} />
|
||||
<div style={{ width: 20, height: 2, background: rgbToHex(OVERLAY_RGB.fcTransfer), borderRadius: 1 }} />
|
||||
FC 환적 연결 (dashed)
|
||||
</div>
|
||||
<div className="li">
|
||||
<div style={{ width: 14, height: 14, borderRadius: "50%", border: "1px solid rgba(245,158,11,.55)", opacity: 0.7 }} />
|
||||
<div style={{ width: 14, height: 14, borderRadius: "50%", border: `1px solid ${rgba(OVERLAY_RGB.fleetRange, 0.75)}`, opacity: 0.8 }} />
|
||||
선단 범위
|
||||
</div>
|
||||
<div className="li">
|
||||
<div style={{ width: 20, height: 2, background: rgbToHex(OVERLAY_RGB.suspicious), borderRadius: 1 }} />
|
||||
FC 환적 연결 (의심)
|
||||
</div>
|
||||
<div className="li">
|
||||
<div
|
||||
style={{
|
||||
width: 20,
|
||||
height: 2,
|
||||
borderRadius: 1,
|
||||
background: "repeating-linear-gradient(to right, rgba(226,232,240,0.55), rgba(226,232,240,0.55) 4px, rgba(0,0,0,0) 4px, rgba(0,0,0,0) 7px)",
|
||||
}}
|
||||
/>
|
||||
예측 벡터 (15분)
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
파일 크기가 너무 크기때문에 변경 상태를 표시하지 않습니다.
Load Diff
210
apps/web/src/widgets/map3d/MaplibreDeckCustomLayer.ts
Normal file
210
apps/web/src/widgets/map3d/MaplibreDeckCustomLayer.ts
Normal file
@ -0,0 +1,210 @@
|
||||
import { Deck, MapController, View, Viewport, type DeckProps } from "@deck.gl/core";
|
||||
import type maplibregl from "maplibre-gl";
|
||||
|
||||
type MatrixViewState = {
|
||||
// MapLibre provides a full world->clip matrix as `modelViewProjectionMatrix`.
|
||||
// We treat it as the viewport's projection matrix and keep viewMatrix identity.
|
||||
projectionMatrix: number[];
|
||||
viewMatrix?: number[];
|
||||
// Deck's View state is constrained to include transition props. We only need one overlapping key
|
||||
// to satisfy TS structural checks without pulling in internal deck.gl types.
|
||||
transitionDuration?: number;
|
||||
};
|
||||
|
||||
class MatrixView extends View<MatrixViewState> {
|
||||
getViewportType(viewState: MatrixViewState): typeof Viewport {
|
||||
void viewState;
|
||||
return Viewport;
|
||||
}
|
||||
|
||||
// Controller isn't used (Deck is created with `controller: false`) but View requires one.
|
||||
protected get ControllerType(): typeof MapController {
|
||||
return MapController;
|
||||
}
|
||||
}
|
||||
|
||||
const IDENTITY_4x4: number[] = [1, 0, 0, 0, 0, 1, 0, 0, 0, 0, 1, 0, 0, 0, 0, 1];
|
||||
type DeckLayerList = NonNullable<DeckProps<MatrixView[]>["layers"]>;
|
||||
|
||||
function readMat4(m: ArrayLike<number>): number[] {
|
||||
const out = new Array<number>(16);
|
||||
for (let i = 0; i < 16; i++) out[i] = m[i] as number;
|
||||
return out;
|
||||
}
|
||||
|
||||
function mat4Changed(a: number[] | undefined, b: ArrayLike<number>): boolean {
|
||||
if (!a || a.length !== 16) return true;
|
||||
// The matrix values change on map move/rotate. A strict compare is fine.
|
||||
for (let i = 0; i < 16; i++) {
|
||||
if (a[i] !== (b[i] as number)) return true;
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
function getDeckLayerId(value: unknown): string | null {
|
||||
if (!value || typeof value !== "object") return null;
|
||||
const candidate = (value as { id?: unknown }).id;
|
||||
return typeof candidate === "string" ? candidate : null;
|
||||
}
|
||||
|
||||
function sanitizeDeckLayers(value: unknown): DeckLayerList {
|
||||
if (!Array.isArray(value)) return [] as DeckLayerList;
|
||||
const out: DeckLayerList = [];
|
||||
const seen = new Set<string>();
|
||||
for (const item of value) {
|
||||
const layerId = getDeckLayerId(item);
|
||||
if (!layerId || seen.has(layerId)) continue;
|
||||
seen.add(layerId);
|
||||
out.push(item as DeckLayerList[number]);
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
export class MaplibreDeckCustomLayer implements maplibregl.CustomLayerInterface {
|
||||
id: string;
|
||||
type = "custom" as const;
|
||||
renderingMode = "3d" as const;
|
||||
|
||||
private _map: maplibregl.Map | null = null;
|
||||
private _deck: Deck<MatrixView[]> | null = null;
|
||||
private _deckProps: Partial<DeckProps<MatrixView[]>> = {};
|
||||
private _viewId: string;
|
||||
private _lastMvp: number[] | undefined;
|
||||
private _finalizeOnRemove: boolean = false;
|
||||
|
||||
constructor(opts: { id: string; viewId: string; deckProps?: Partial<DeckProps<MatrixView[]>> }) {
|
||||
this.id = opts.id;
|
||||
this._viewId = opts.viewId;
|
||||
this._deckProps = opts.deckProps ?? {};
|
||||
}
|
||||
|
||||
get deck(): Deck<MatrixView[]> | null {
|
||||
return this._deck;
|
||||
}
|
||||
|
||||
requestFinalize() {
|
||||
this._finalizeOnRemove = true;
|
||||
}
|
||||
|
||||
setProps(next: Partial<DeckProps<MatrixView[]>>) {
|
||||
const normalized = next.layers ? { ...next, layers: sanitizeDeckLayers(next.layers) } : next;
|
||||
this._deckProps = { ...this._deckProps, ...normalized };
|
||||
if (this._deck) this._deck.setProps(this._deckProps as DeckProps<MatrixView[]>);
|
||||
this._map?.triggerRepaint();
|
||||
}
|
||||
|
||||
onAdd(map: maplibregl.Map, gl: WebGLRenderingContext | WebGL2RenderingContext): void {
|
||||
this._finalizeOnRemove = false;
|
||||
this._map = map;
|
||||
|
||||
if (this._deck) {
|
||||
// Re-attached after a style change; keep the existing Deck instance so we don't reuse
|
||||
// finalized Layer objects (Deck does not allow that).
|
||||
this._lastMvp = undefined;
|
||||
const nextDeckProps = {
|
||||
...this._deckProps,
|
||||
layers: sanitizeDeckLayers(this._deckProps.layers),
|
||||
canvas: map.getCanvas(),
|
||||
// Ensure any pending redraw requests trigger a map repaint again.
|
||||
_customRender: () => map.triggerRepaint(),
|
||||
};
|
||||
this._deck.setProps(nextDeckProps as DeckProps<MatrixView[]>);
|
||||
return;
|
||||
}
|
||||
|
||||
const deck = new Deck<MatrixView[]>({
|
||||
...this._deckProps,
|
||||
layers: sanitizeDeckLayers(this._deckProps.layers),
|
||||
// Share MapLibre's WebGL context + canvas (single context).
|
||||
gl: gl as WebGL2RenderingContext,
|
||||
canvas: map.getCanvas(),
|
||||
width: null,
|
||||
height: null,
|
||||
// Let MapLibre own pointer/touch behaviors on the shared canvas.
|
||||
touchAction: "none",
|
||||
controller: false,
|
||||
views: this._deckProps.views ?? [new MatrixView({ id: this._viewId })],
|
||||
viewState: this._deckProps.viewState ?? { [this._viewId]: { projectionMatrix: IDENTITY_4x4 } },
|
||||
// Only request a repaint when Deck thinks it needs one. Drawing happens in `render()`.
|
||||
_customRender: () => map.triggerRepaint(),
|
||||
parameters: {
|
||||
// Match @deck.gl/mapbox interleaved defaults (premultiplied blending).
|
||||
depthWriteEnabled: true,
|
||||
depthCompare: "less-equal",
|
||||
depthBias: 0,
|
||||
blend: true,
|
||||
blendColorSrcFactor: "src-alpha",
|
||||
blendColorDstFactor: "one-minus-src-alpha",
|
||||
blendAlphaSrcFactor: "one",
|
||||
blendAlphaDstFactor: "one-minus-src-alpha",
|
||||
blendColorOperation: "add",
|
||||
blendAlphaOperation: "add",
|
||||
...this._deckProps.parameters,
|
||||
},
|
||||
});
|
||||
|
||||
this._deck = deck;
|
||||
}
|
||||
|
||||
onRemove(): void {
|
||||
const deck = this._deck;
|
||||
const map = this._map;
|
||||
this._map = null;
|
||||
this._lastMvp = undefined;
|
||||
|
||||
if (!deck) return;
|
||||
|
||||
if (this._finalizeOnRemove) {
|
||||
deck.finalize();
|
||||
this._deck = null;
|
||||
return;
|
||||
}
|
||||
|
||||
// Likely a base style swap; keep Deck instance alive and re-attach in onAdd().
|
||||
// Disable repaint requests until we get re-attached.
|
||||
try {
|
||||
deck.setProps({ _customRender: () => void 0 } as Partial<DeckProps<MatrixView[]>>);
|
||||
} catch {
|
||||
// ignore
|
||||
}
|
||||
try {
|
||||
map?.triggerRepaint();
|
||||
} catch {
|
||||
// ignore
|
||||
}
|
||||
}
|
||||
|
||||
render(_gl: WebGLRenderingContext | WebGL2RenderingContext, options: maplibregl.CustomRenderMethodInput): void {
|
||||
const deck = this._deck;
|
||||
if (!this._map) return;
|
||||
if (!deck || !deck.isInitialized) return;
|
||||
// Deck reports `isInitialized` once `viewManager` exists, but we still see rare cases during
|
||||
// style/projection transitions where internal managers are temporarily null (or tearing down).
|
||||
// Guard before calling the internal `_drawLayers` to avoid crashing the whole map render.
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
const internal = deck as any;
|
||||
if (!internal.layerManager || !internal.viewManager) return;
|
||||
|
||||
// MapLibre gives us a world->clip matrix for the current projection (mercator/globe).
|
||||
// For globe, this matrix expects unit-sphere world coordinates (see MapLibre's globe transform).
|
||||
if (mat4Changed(this._lastMvp, options.modelViewProjectionMatrix)) {
|
||||
const projectionMatrix = readMat4(options.modelViewProjectionMatrix);
|
||||
this._lastMvp = projectionMatrix;
|
||||
deck.setProps({ viewState: { [this._viewId]: { projectionMatrix, viewMatrix: IDENTITY_4x4 } } });
|
||||
}
|
||||
|
||||
try {
|
||||
deck._drawLayers("maplibre-custom", {
|
||||
clearCanvas: false,
|
||||
clearStack: true,
|
||||
});
|
||||
} catch (e) {
|
||||
// Rendering can fail transiently during style/projection transitions.
|
||||
// Keep the map responsive and request a clean pass on next frame.
|
||||
console.warn("Deck render sync failed, skipping frame:", e);
|
||||
requestAnimationFrame(() => {
|
||||
this._map?.triggerRepaint();
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
161
apps/web/src/widgets/map3d/constants.ts
Normal file
161
apps/web/src/widgets/map3d/constants.ts
Normal file
@ -0,0 +1,161 @@
|
||||
import {
|
||||
LEGACY_CODE_COLORS_RGB,
|
||||
OVERLAY_RGB,
|
||||
rgba as rgbaCss,
|
||||
} from '../../shared/lib/map/palette';
|
||||
|
||||
// ── Re-export palette aliases used throughout Map3D ──
|
||||
|
||||
export const LEGACY_CODE_COLORS = LEGACY_CODE_COLORS_RGB;
|
||||
|
||||
const OVERLAY_PAIR_NORMAL_RGB = OVERLAY_RGB.pairNormal;
|
||||
const OVERLAY_PAIR_WARN_RGB = OVERLAY_RGB.pairWarn;
|
||||
const OVERLAY_FC_TRANSFER_RGB = OVERLAY_RGB.fcTransfer;
|
||||
const OVERLAY_FLEET_RANGE_RGB = OVERLAY_RGB.fleetRange;
|
||||
const OVERLAY_SUSPICIOUS_RGB = OVERLAY_RGB.suspicious;
|
||||
|
||||
// ── Ship icon mapping (Deck.gl IconLayer) ──
|
||||
|
||||
export const SHIP_ICON_MAPPING = {
|
||||
ship: {
|
||||
x: 0,
|
||||
y: 0,
|
||||
width: 128,
|
||||
height: 128,
|
||||
anchorX: 64,
|
||||
anchorY: 64,
|
||||
mask: true,
|
||||
},
|
||||
} as const;
|
||||
|
||||
// ── Ship constants ──
|
||||
|
||||
export const ANCHOR_SPEED_THRESHOLD_KN = 1;
|
||||
export const ANCHORED_SHIP_ICON_ID = 'ship-globe-anchored-icon';
|
||||
|
||||
// ── Geometry constants ──
|
||||
|
||||
export const DEG2RAD = Math.PI / 180;
|
||||
export const RAD2DEG = 180 / Math.PI;
|
||||
export const EARTH_RADIUS_M = 6371008.8; // MapLibre's `earthRadius`
|
||||
export const GLOBE_ICON_HEADING_OFFSET_DEG = 0;
|
||||
|
||||
// ── Ship color constants ──
|
||||
|
||||
export const MAP_SELECTED_SHIP_RGB: [number, number, number] = [34, 211, 238];
|
||||
export const MAP_HIGHLIGHT_SHIP_RGB: [number, number, number] = [245, 158, 11];
|
||||
export const MAP_DEFAULT_SHIP_RGB: [number, number, number] = [100, 116, 139];
|
||||
|
||||
// ── Ship outline / halo contrast colors ──
|
||||
|
||||
export const HALO_OUTLINE_COLOR: [number, number, number, number] = [210, 225, 240, 155];
|
||||
export const HALO_OUTLINE_COLOR_SELECTED: [number, number, number, number] = [14, 234, 255, 230];
|
||||
export const HALO_OUTLINE_COLOR_HIGHLIGHTED: [number, number, number, number] = [245, 158, 11, 210];
|
||||
export const GLOBE_OUTLINE_PERMITTED = 'rgba(210,225,240,0.62)';
|
||||
export const GLOBE_OUTLINE_OTHER = 'rgba(160,175,195,0.35)';
|
||||
|
||||
// ── Flat map icon sizes ──
|
||||
|
||||
export const FLAT_SHIP_ICON_SIZE = 19;
|
||||
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_SELECTED = 18;
|
||||
export const FLAT_LEGACY_HALO_RADIUS_HIGHLIGHTED = 16;
|
||||
export const EMPTY_MMSI_SET = new Set<number>();
|
||||
|
||||
// ── Deck.gl view ID ──
|
||||
|
||||
export const DECK_VIEW_ID = 'mapbox';
|
||||
|
||||
// ── Depth params ──
|
||||
|
||||
export const DEPTH_DISABLED_PARAMS = {
|
||||
depthCompare: 'always',
|
||||
depthWriteEnabled: false,
|
||||
} as const;
|
||||
|
||||
export const GLOBE_OVERLAY_PARAMS = {
|
||||
depthCompare: 'less-equal',
|
||||
depthWriteEnabled: false,
|
||||
} as const;
|
||||
|
||||
// ── Deck.gl color constants (avoid per-object allocations inside accessors) ──
|
||||
|
||||
export const PAIR_RANGE_NORMAL_DECK: [number, number, number, number] = [
|
||||
OVERLAY_PAIR_NORMAL_RGB[0], OVERLAY_PAIR_NORMAL_RGB[1], OVERLAY_PAIR_NORMAL_RGB[2], 110,
|
||||
];
|
||||
export const PAIR_RANGE_WARN_DECK: [number, number, number, number] = [
|
||||
OVERLAY_PAIR_WARN_RGB[0], OVERLAY_PAIR_WARN_RGB[1], OVERLAY_PAIR_WARN_RGB[2], 170,
|
||||
];
|
||||
export const PAIR_LINE_NORMAL_DECK: [number, number, number, number] = [
|
||||
OVERLAY_PAIR_NORMAL_RGB[0], OVERLAY_PAIR_NORMAL_RGB[1], OVERLAY_PAIR_NORMAL_RGB[2], 85,
|
||||
];
|
||||
export const PAIR_LINE_WARN_DECK: [number, number, number, number] = [
|
||||
OVERLAY_PAIR_WARN_RGB[0], OVERLAY_PAIR_WARN_RGB[1], OVERLAY_PAIR_WARN_RGB[2], 220,
|
||||
];
|
||||
export const FC_LINE_NORMAL_DECK: [number, number, number, number] = [
|
||||
OVERLAY_FC_TRANSFER_RGB[0], OVERLAY_FC_TRANSFER_RGB[1], OVERLAY_FC_TRANSFER_RGB[2], 200,
|
||||
];
|
||||
export const FC_LINE_SUSPICIOUS_DECK: [number, number, number, number] = [
|
||||
OVERLAY_SUSPICIOUS_RGB[0], OVERLAY_SUSPICIOUS_RGB[1], OVERLAY_SUSPICIOUS_RGB[2], 220,
|
||||
];
|
||||
export const FLEET_RANGE_LINE_DECK: [number, number, number, number] = [
|
||||
OVERLAY_FLEET_RANGE_RGB[0], OVERLAY_FLEET_RANGE_RGB[1], OVERLAY_FLEET_RANGE_RGB[2], 140,
|
||||
];
|
||||
export const FLEET_RANGE_FILL_DECK: [number, number, number, number] = [
|
||||
OVERLAY_FLEET_RANGE_RGB[0], OVERLAY_FLEET_RANGE_RGB[1], OVERLAY_FLEET_RANGE_RGB[2], 6,
|
||||
];
|
||||
|
||||
// ── Highlighted variants ──
|
||||
|
||||
export const PAIR_RANGE_NORMAL_DECK_HL: [number, number, number, number] = [
|
||||
OVERLAY_PAIR_NORMAL_RGB[0], OVERLAY_PAIR_NORMAL_RGB[1], OVERLAY_PAIR_NORMAL_RGB[2], 200,
|
||||
];
|
||||
export const PAIR_RANGE_WARN_DECK_HL: [number, number, number, number] = [
|
||||
OVERLAY_PAIR_WARN_RGB[0], OVERLAY_PAIR_WARN_RGB[1], OVERLAY_PAIR_WARN_RGB[2], 240,
|
||||
];
|
||||
export const PAIR_LINE_NORMAL_DECK_HL: [number, number, number, number] = [
|
||||
OVERLAY_PAIR_NORMAL_RGB[0], OVERLAY_PAIR_NORMAL_RGB[1], OVERLAY_PAIR_NORMAL_RGB[2], 245,
|
||||
];
|
||||
export const PAIR_LINE_WARN_DECK_HL: [number, number, number, number] = [
|
||||
OVERLAY_PAIR_WARN_RGB[0], OVERLAY_PAIR_WARN_RGB[1], OVERLAY_PAIR_WARN_RGB[2], 245,
|
||||
];
|
||||
export const FC_LINE_NORMAL_DECK_HL: [number, number, number, number] = [
|
||||
OVERLAY_FC_TRANSFER_RGB[0], OVERLAY_FC_TRANSFER_RGB[1], OVERLAY_FC_TRANSFER_RGB[2], 235,
|
||||
];
|
||||
export const FC_LINE_SUSPICIOUS_DECK_HL: [number, number, number, number] = [
|
||||
OVERLAY_SUSPICIOUS_RGB[0], OVERLAY_SUSPICIOUS_RGB[1], OVERLAY_SUSPICIOUS_RGB[2], 245,
|
||||
];
|
||||
export const FLEET_RANGE_LINE_DECK_HL: [number, number, number, number] = [
|
||||
OVERLAY_FLEET_RANGE_RGB[0], OVERLAY_FLEET_RANGE_RGB[1], OVERLAY_FLEET_RANGE_RGB[2], 220,
|
||||
];
|
||||
export const FLEET_RANGE_FILL_DECK_HL: [number, number, number, number] = [
|
||||
OVERLAY_FLEET_RANGE_RGB[0], OVERLAY_FLEET_RANGE_RGB[1], OVERLAY_FLEET_RANGE_RGB[2], 42,
|
||||
];
|
||||
|
||||
// ── MapLibre overlay colors ──
|
||||
|
||||
export const PAIR_LINE_NORMAL_ML = rgbaCss(OVERLAY_PAIR_NORMAL_RGB, 0.55);
|
||||
export const PAIR_LINE_WARN_ML = rgbaCss(OVERLAY_PAIR_WARN_RGB, 0.95);
|
||||
export const PAIR_LINE_NORMAL_ML_HL = rgbaCss(OVERLAY_PAIR_NORMAL_RGB, 0.95);
|
||||
export const PAIR_LINE_WARN_ML_HL = rgbaCss(OVERLAY_PAIR_WARN_RGB, 0.98);
|
||||
|
||||
export const PAIR_RANGE_NORMAL_ML = rgbaCss(OVERLAY_PAIR_NORMAL_RGB, 0.45);
|
||||
export const PAIR_RANGE_WARN_ML = rgbaCss(OVERLAY_PAIR_WARN_RGB, 0.75);
|
||||
export const PAIR_RANGE_NORMAL_ML_HL = rgbaCss(OVERLAY_PAIR_NORMAL_RGB, 0.92);
|
||||
export const PAIR_RANGE_WARN_ML_HL = rgbaCss(OVERLAY_PAIR_WARN_RGB, 0.92);
|
||||
|
||||
export const FC_LINE_NORMAL_ML = rgbaCss(OVERLAY_FC_TRANSFER_RGB, 0.92);
|
||||
export const FC_LINE_SUSPICIOUS_ML = rgbaCss(OVERLAY_SUSPICIOUS_RGB, 0.95);
|
||||
export const FC_LINE_NORMAL_ML_HL = rgbaCss(OVERLAY_FC_TRANSFER_RGB, 0.98);
|
||||
export const FC_LINE_SUSPICIOUS_ML_HL = rgbaCss(OVERLAY_SUSPICIOUS_RGB, 0.98);
|
||||
|
||||
export const FLEET_FILL_ML = rgbaCss(OVERLAY_FLEET_RANGE_RGB, 0.02);
|
||||
export const FLEET_FILL_ML_HL = rgbaCss(OVERLAY_FLEET_RANGE_RGB, 0.16);
|
||||
export const FLEET_LINE_ML = rgbaCss(OVERLAY_FLEET_RANGE_RGB, 0.65);
|
||||
export const FLEET_LINE_ML_HL = rgbaCss(OVERLAY_FLEET_RANGE_RGB, 0.95);
|
||||
|
||||
// ── Bathymetry zoom ranges ──
|
||||
// NOTE: BATHY_ZOOM_RANGES는 bathymetry.ts에서 로컬 정의 + applyBathymetryZoomProfile()에서 사용
|
||||
// 이 파일의 export는 사용처가 없어 제거됨 (2026-02-16)
|
||||
132
apps/web/src/widgets/map3d/hooks/useBaseMapToggle.ts
Normal file
132
apps/web/src/widgets/map3d/hooks/useBaseMapToggle.ts
Normal file
@ -0,0 +1,132 @@
|
||||
import { useEffect, useRef, type MutableRefObject } from 'react';
|
||||
import type maplibregl from 'maplibre-gl';
|
||||
import type { BaseMapId, MapProjectionId } from '../types';
|
||||
import { kickRepaint, onMapStyleReady, getLayerId } from '../lib/mapCore';
|
||||
import { ensureSeamarkOverlay } from '../layers/seamark';
|
||||
import { applyBathymetryZoomProfile, resolveMapStyle } from '../layers/bathymetry';
|
||||
|
||||
export function useBaseMapToggle(
|
||||
mapRef: MutableRefObject<maplibregl.Map | null>,
|
||||
opts: {
|
||||
baseMap: BaseMapId;
|
||||
projection: MapProjectionId;
|
||||
showSeamark: boolean;
|
||||
mapSyncEpoch: number;
|
||||
pulseMapSync: () => void;
|
||||
},
|
||||
) {
|
||||
const { baseMap, projection, showSeamark, mapSyncEpoch, pulseMapSync } = opts;
|
||||
|
||||
const showSeamarkRef = useRef(showSeamark);
|
||||
const bathyZoomProfileKeyRef = useRef<string>('');
|
||||
|
||||
useEffect(() => {
|
||||
showSeamarkRef.current = showSeamark;
|
||||
}, [showSeamark]);
|
||||
|
||||
// Base map style toggle
|
||||
useEffect(() => {
|
||||
const map = mapRef.current;
|
||||
if (!map) return;
|
||||
|
||||
let cancelled = false;
|
||||
const controller = new AbortController();
|
||||
let stop: (() => void) | null = null;
|
||||
|
||||
(async () => {
|
||||
try {
|
||||
const style = await resolveMapStyle(baseMap, controller.signal);
|
||||
if (cancelled) return;
|
||||
map.setStyle(style, { diff: false });
|
||||
stop = onMapStyleReady(map, () => {
|
||||
kickRepaint(map);
|
||||
requestAnimationFrame(() => kickRepaint(map));
|
||||
pulseMapSync();
|
||||
});
|
||||
} catch (e) {
|
||||
if (cancelled) return;
|
||||
console.warn('Base map switch failed:', e);
|
||||
}
|
||||
})();
|
||||
|
||||
return () => {
|
||||
cancelled = true;
|
||||
controller.abort();
|
||||
stop?.();
|
||||
};
|
||||
}, [baseMap]);
|
||||
|
||||
// Bathymetry zoom profile + water layer visibility
|
||||
useEffect(() => {
|
||||
const map = mapRef.current;
|
||||
if (!map) return;
|
||||
|
||||
const apply = () => {
|
||||
if (!map.isStyleLoaded()) return;
|
||||
const seaVisibility = 'visible' as const;
|
||||
const seaRegex = /(water|sea|ocean|river|lake|coast|bay)/i;
|
||||
|
||||
const nextProfileKey = `bathyZoomV1|${baseMap}|${projection}`;
|
||||
if (bathyZoomProfileKeyRef.current !== nextProfileKey) {
|
||||
applyBathymetryZoomProfile(map, baseMap, projection);
|
||||
bathyZoomProfileKeyRef.current = nextProfileKey;
|
||||
kickRepaint(map);
|
||||
}
|
||||
|
||||
try {
|
||||
const style = map.getStyle();
|
||||
const styleLayers = style && Array.isArray(style.layers) ? style.layers : [];
|
||||
for (const layer of styleLayers) {
|
||||
const id = getLayerId(layer);
|
||||
if (!id) continue;
|
||||
const sourceLayer = String((layer as Record<string, unknown>)['source-layer'] ?? '').toLowerCase();
|
||||
const source = String((layer as { source?: unknown }).source ?? '').toLowerCase();
|
||||
const type = String((layer as { type?: unknown }).type ?? '').toLowerCase();
|
||||
const isSea = seaRegex.test(id) || seaRegex.test(sourceLayer) || seaRegex.test(source);
|
||||
const isRaster = type === 'raster';
|
||||
if (!isSea) continue;
|
||||
if (!map.getLayer(id)) continue;
|
||||
if (isRaster && id === 'seamark') continue;
|
||||
try {
|
||||
map.setLayoutProperty(id, 'visibility', seaVisibility);
|
||||
} catch {
|
||||
// ignore
|
||||
}
|
||||
}
|
||||
} catch {
|
||||
// ignore
|
||||
}
|
||||
};
|
||||
|
||||
const stop = onMapStyleReady(map, apply);
|
||||
return () => {
|
||||
stop();
|
||||
};
|
||||
}, [projection, baseMap, mapSyncEpoch]);
|
||||
|
||||
// Seamark toggle
|
||||
useEffect(() => {
|
||||
const map = mapRef.current;
|
||||
if (!map) return;
|
||||
if (showSeamark) {
|
||||
try {
|
||||
ensureSeamarkOverlay(map, 'bathymetry-lines');
|
||||
map.setPaintProperty('seamark', 'raster-opacity', 0.85);
|
||||
} catch {
|
||||
// ignore until style is ready
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
if (map.getLayer('seamark')) map.removeLayer('seamark');
|
||||
} catch {
|
||||
// ignore
|
||||
}
|
||||
try {
|
||||
if (map.getSource('seamark')) map.removeSource('seamark');
|
||||
} catch {
|
||||
// ignore
|
||||
}
|
||||
}, [showSeamark]);
|
||||
}
|
||||
662
apps/web/src/widgets/map3d/hooks/useDeckLayers.ts
Normal file
662
apps/web/src/widgets/map3d/hooks/useDeckLayers.ts
Normal file
@ -0,0 +1,662 @@
|
||||
import { useEffect, useMemo, type MutableRefObject } from 'react';
|
||||
import { HexagonLayer } from '@deck.gl/aggregation-layers';
|
||||
import { IconLayer, LineLayer, ScatterplotLayer } from '@deck.gl/layers';
|
||||
import { MapboxOverlay } from '@deck.gl/mapbox';
|
||||
import { type PickingInfo } from '@deck.gl/core';
|
||||
import type { AisTarget } from '../../../entities/aisTarget/model/types';
|
||||
import type { LegacyVesselInfo } from '../../../entities/legacyVessel/model/types';
|
||||
import type { FcLink, FleetCircle, PairLink } from '../../../features/legacyDashboard/model/types';
|
||||
import type { MapToggleState } from '../../../features/mapToggles/MapToggles';
|
||||
import type { DashSeg, Map3DSettings, MapProjectionId, PairRangeCircle } from '../types';
|
||||
import { MaplibreDeckCustomLayer } from '../MaplibreDeckCustomLayer';
|
||||
import {
|
||||
SHIP_ICON_MAPPING,
|
||||
FLAT_SHIP_ICON_SIZE,
|
||||
FLAT_SHIP_ICON_SIZE_SELECTED,
|
||||
FLAT_SHIP_ICON_SIZE_HIGHLIGHTED,
|
||||
FLAT_LEGACY_HALO_RADIUS,
|
||||
FLAT_LEGACY_HALO_RADIUS_SELECTED,
|
||||
FLAT_LEGACY_HALO_RADIUS_HIGHLIGHTED,
|
||||
EMPTY_MMSI_SET,
|
||||
DEPTH_DISABLED_PARAMS,
|
||||
GLOBE_OVERLAY_PARAMS,
|
||||
HALO_OUTLINE_COLOR,
|
||||
HALO_OUTLINE_COLOR_SELECTED,
|
||||
HALO_OUTLINE_COLOR_HIGHLIGHTED,
|
||||
PAIR_RANGE_NORMAL_DECK,
|
||||
PAIR_RANGE_WARN_DECK,
|
||||
PAIR_LINE_NORMAL_DECK,
|
||||
PAIR_LINE_WARN_DECK,
|
||||
FC_LINE_NORMAL_DECK,
|
||||
FC_LINE_SUSPICIOUS_DECK,
|
||||
FLEET_RANGE_LINE_DECK,
|
||||
FLEET_RANGE_FILL_DECK,
|
||||
PAIR_RANGE_NORMAL_DECK_HL,
|
||||
PAIR_RANGE_WARN_DECK_HL,
|
||||
PAIR_LINE_NORMAL_DECK_HL,
|
||||
PAIR_LINE_WARN_DECK_HL,
|
||||
FC_LINE_NORMAL_DECK_HL,
|
||||
FC_LINE_SUSPICIOUS_DECK_HL,
|
||||
FLEET_RANGE_LINE_DECK_HL,
|
||||
FLEET_RANGE_FILL_DECK_HL,
|
||||
} from '../constants';
|
||||
import { toSafeNumber } from '../lib/setUtils';
|
||||
import { getDisplayHeading, getShipColor } from '../lib/shipUtils';
|
||||
import {
|
||||
getShipTooltipHtml,
|
||||
getPairLinkTooltipHtml,
|
||||
getFcLinkTooltipHtml,
|
||||
getRangeTooltipHtml,
|
||||
getFleetCircleTooltipHtml,
|
||||
} from '../lib/tooltips';
|
||||
import { sanitizeDeckLayerList } from '../lib/mapCore';
|
||||
|
||||
export function useDeckLayers(
|
||||
mapRef: MutableRefObject<import('maplibre-gl').Map | null>,
|
||||
overlayRef: MutableRefObject<MapboxOverlay | null>,
|
||||
globeDeckLayerRef: MutableRefObject<MaplibreDeckCustomLayer | null>,
|
||||
projectionBusyRef: MutableRefObject<boolean>,
|
||||
opts: {
|
||||
projection: MapProjectionId;
|
||||
settings: Map3DSettings;
|
||||
shipLayerData: AisTarget[];
|
||||
shipOverlayLayerData: AisTarget[];
|
||||
shipData: AisTarget[];
|
||||
legacyHits: Map<number, LegacyVesselInfo> | null | undefined;
|
||||
pairLinks: PairLink[] | undefined;
|
||||
fcLinks: FcLink[] | undefined;
|
||||
fcDashed: DashSeg[];
|
||||
fleetCircles: FleetCircle[] | undefined;
|
||||
pairRanges: PairRangeCircle[];
|
||||
pairLinksInteractive: PairLink[];
|
||||
pairRangesInteractive: PairRangeCircle[];
|
||||
fcLinesInteractive: DashSeg[];
|
||||
fleetCirclesInteractive: FleetCircle[];
|
||||
overlays: MapToggleState;
|
||||
shipByMmsi: Map<number, AisTarget>;
|
||||
selectedMmsi: number | null;
|
||||
shipHighlightSet: Set<number>;
|
||||
isHighlightedFleet: (ownerKey: string, vesselMmsis: number[]) => boolean;
|
||||
isHighlightedPair: (aMmsi: number, bMmsi: number) => boolean;
|
||||
isHighlightedMmsi: (mmsi: number) => boolean;
|
||||
clearDeckHoverPairs: () => void;
|
||||
clearDeckHoverMmsi: () => void;
|
||||
clearMapFleetHoverState: () => void;
|
||||
setDeckHoverPairs: (next: number[]) => void;
|
||||
setDeckHoverMmsi: (next: number[]) => void;
|
||||
setMapFleetHoverState: (ownerKey: string | null, vesselMmsis: number[]) => void;
|
||||
toFleetMmsiList: (value: unknown) => number[];
|
||||
touchDeckHoverState: (isHover: boolean) => void;
|
||||
hasAuxiliarySelectModifier: (ev?: { shiftKey?: boolean; ctrlKey?: boolean; metaKey?: boolean } | null) => boolean;
|
||||
onDeckSelectOrHighlight: (info: unknown, allowMultiSelect?: boolean) => void;
|
||||
onSelectMmsi: (mmsi: number | null) => void;
|
||||
onToggleHighlightMmsi?: (mmsi: number) => void;
|
||||
ensureMercatorOverlay: () => MapboxOverlay | null;
|
||||
projectionRef: MutableRefObject<MapProjectionId>;
|
||||
},
|
||||
) {
|
||||
const {
|
||||
projection, settings, shipLayerData, shipOverlayLayerData, shipData,
|
||||
legacyHits, pairLinks, fcDashed, fleetCircles, pairRanges,
|
||||
pairLinksInteractive, pairRangesInteractive, fcLinesInteractive, fleetCirclesInteractive,
|
||||
overlays, shipByMmsi, selectedMmsi, shipHighlightSet,
|
||||
isHighlightedFleet, isHighlightedPair, isHighlightedMmsi,
|
||||
clearDeckHoverPairs, clearDeckHoverMmsi, clearMapFleetHoverState,
|
||||
setDeckHoverPairs, setDeckHoverMmsi, setMapFleetHoverState,
|
||||
toFleetMmsiList, touchDeckHoverState, hasAuxiliarySelectModifier,
|
||||
onDeckSelectOrHighlight, onSelectMmsi, onToggleHighlightMmsi,
|
||||
ensureMercatorOverlay, projectionRef,
|
||||
} = opts;
|
||||
|
||||
const legacyTargets = useMemo(() => {
|
||||
if (!legacyHits) return [];
|
||||
return shipData.filter((t) => legacyHits.has(t.mmsi));
|
||||
}, [shipData, legacyHits]);
|
||||
|
||||
const legacyTargetsOrdered = useMemo(() => {
|
||||
if (legacyTargets.length === 0) return legacyTargets;
|
||||
const layer = [...legacyTargets];
|
||||
layer.sort((a, b) => a.mmsi - b.mmsi);
|
||||
return layer;
|
||||
}, [legacyTargets]);
|
||||
|
||||
const legacyOverlayTargets = useMemo(() => {
|
||||
if (shipHighlightSet.size === 0) return [];
|
||||
return legacyTargets.filter((target) => shipHighlightSet.has(target.mmsi));
|
||||
}, [legacyTargets, shipHighlightSet]);
|
||||
|
||||
// Mercator Deck layers
|
||||
useEffect(() => {
|
||||
const map = mapRef.current;
|
||||
if (!map) return;
|
||||
if (projection !== 'mercator' || projectionBusyRef.current) {
|
||||
if (projection !== 'mercator') {
|
||||
try {
|
||||
if (overlayRef.current) overlayRef.current.setProps({ layers: [] } as never);
|
||||
} catch {
|
||||
// ignore
|
||||
}
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
const deckTarget = ensureMercatorOverlay();
|
||||
if (!deckTarget) return;
|
||||
|
||||
const layers: unknown[] = [];
|
||||
const overlayParams = DEPTH_DISABLED_PARAMS;
|
||||
const clearDeckHover = () => {
|
||||
touchDeckHoverState(false);
|
||||
};
|
||||
const isTargetShip = (mmsi: number) => (legacyHits ? legacyHits.has(mmsi) : false);
|
||||
const shipOtherData: AisTarget[] = [];
|
||||
const shipTargetData: AisTarget[] = [];
|
||||
for (const t of shipLayerData) {
|
||||
if (isTargetShip(t.mmsi)) shipTargetData.push(t);
|
||||
else shipOtherData.push(t);
|
||||
}
|
||||
const shipOverlayOtherData: AisTarget[] = [];
|
||||
const shipOverlayTargetData: AisTarget[] = [];
|
||||
for (const t of shipOverlayLayerData) {
|
||||
if (isTargetShip(t.mmsi)) shipOverlayTargetData.push(t);
|
||||
else shipOverlayOtherData.push(t);
|
||||
}
|
||||
|
||||
if (settings.showDensity) {
|
||||
layers.push(
|
||||
new HexagonLayer<AisTarget>({
|
||||
id: 'density',
|
||||
data: shipLayerData,
|
||||
pickable: true,
|
||||
extruded: true,
|
||||
radius: 2500,
|
||||
elevationScale: 35,
|
||||
coverage: 0.92,
|
||||
opacity: 0.35,
|
||||
getPosition: (d) => [d.lon, d.lat],
|
||||
}),
|
||||
);
|
||||
}
|
||||
|
||||
if (overlays.pairRange && pairRanges.length > 0) {
|
||||
layers.push(
|
||||
new ScatterplotLayer<PairRangeCircle>({
|
||||
id: 'pair-range',
|
||||
data: pairRanges,
|
||||
pickable: true,
|
||||
billboard: false,
|
||||
parameters: overlayParams,
|
||||
filled: false,
|
||||
stroked: true,
|
||||
radiusUnits: 'meters',
|
||||
getRadius: (d) => d.radiusNm * 1852,
|
||||
radiusMinPixels: 10,
|
||||
lineWidthUnits: 'pixels',
|
||||
getLineWidth: () => 1,
|
||||
getLineColor: (d) => (d.warn ? PAIR_RANGE_WARN_DECK : PAIR_RANGE_NORMAL_DECK),
|
||||
getPosition: (d) => d.center,
|
||||
onHover: (info) => {
|
||||
if (!info.object) { clearDeckHover(); return; }
|
||||
touchDeckHoverState(true);
|
||||
const p = info.object as PairRangeCircle;
|
||||
setDeckHoverPairs([p.aMmsi, p.bMmsi]);
|
||||
setDeckHoverMmsi([p.aMmsi, p.bMmsi]);
|
||||
clearMapFleetHoverState();
|
||||
},
|
||||
onClick: (info) => {
|
||||
if (!info.object) { onSelectMmsi(null); return; }
|
||||
const obj = info.object as PairRangeCircle;
|
||||
const sourceEvent = (info as { srcEvent?: { shiftKey?: boolean; ctrlKey?: boolean; metaKey?: boolean } | null }).srcEvent;
|
||||
if (sourceEvent && hasAuxiliarySelectModifier(sourceEvent)) {
|
||||
onToggleHighlightMmsi?.(obj.aMmsi);
|
||||
onToggleHighlightMmsi?.(obj.bMmsi);
|
||||
return;
|
||||
}
|
||||
onDeckSelectOrHighlight({ mmsi: obj.aMmsi });
|
||||
},
|
||||
}),
|
||||
);
|
||||
}
|
||||
|
||||
if (overlays.pairLines && (pairLinks?.length ?? 0) > 0) {
|
||||
layers.push(
|
||||
new LineLayer<PairLink>({
|
||||
id: 'pair-lines',
|
||||
data: pairLinks,
|
||||
pickable: true,
|
||||
parameters: overlayParams,
|
||||
getSourcePosition: (d) => d.from,
|
||||
getTargetPosition: (d) => d.to,
|
||||
getColor: (d) => (d.warn ? PAIR_LINE_WARN_DECK : PAIR_LINE_NORMAL_DECK),
|
||||
getWidth: (d) => (d.warn ? 2.2 : 1.4),
|
||||
widthUnits: 'pixels',
|
||||
onHover: (info) => {
|
||||
if (!info.object) { clearDeckHover(); return; }
|
||||
touchDeckHoverState(true);
|
||||
const obj = info.object as PairLink;
|
||||
setDeckHoverPairs([obj.aMmsi, obj.bMmsi]);
|
||||
setDeckHoverMmsi([obj.aMmsi, obj.bMmsi]);
|
||||
clearMapFleetHoverState();
|
||||
},
|
||||
onClick: (info) => {
|
||||
if (!info.object) return;
|
||||
const obj = info.object as PairLink;
|
||||
const sourceEvent = (info as { srcEvent?: { shiftKey?: boolean; ctrlKey?: boolean; metaKey?: boolean } | null }).srcEvent;
|
||||
if (sourceEvent && hasAuxiliarySelectModifier(sourceEvent)) {
|
||||
onToggleHighlightMmsi?.(obj.aMmsi);
|
||||
onToggleHighlightMmsi?.(obj.bMmsi);
|
||||
return;
|
||||
}
|
||||
onDeckSelectOrHighlight({ mmsi: obj.aMmsi });
|
||||
},
|
||||
}),
|
||||
);
|
||||
}
|
||||
|
||||
if (overlays.fcLines && fcDashed.length > 0) {
|
||||
layers.push(
|
||||
new LineLayer<DashSeg>({
|
||||
id: 'fc-lines',
|
||||
data: fcDashed,
|
||||
pickable: true,
|
||||
parameters: overlayParams,
|
||||
getSourcePosition: (d) => d.from,
|
||||
getTargetPosition: (d) => d.to,
|
||||
getColor: (d) => (d.suspicious ? FC_LINE_SUSPICIOUS_DECK : FC_LINE_NORMAL_DECK),
|
||||
getWidth: () => 1.3,
|
||||
widthUnits: 'pixels',
|
||||
onHover: (info) => {
|
||||
if (!info.object) { clearDeckHover(); return; }
|
||||
touchDeckHoverState(true);
|
||||
const obj = info.object as DashSeg;
|
||||
if (obj.fromMmsi == null || obj.toMmsi == null) { clearDeckHover(); return; }
|
||||
setDeckHoverPairs([obj.fromMmsi, obj.toMmsi]);
|
||||
setDeckHoverMmsi([obj.fromMmsi, obj.toMmsi]);
|
||||
clearMapFleetHoverState();
|
||||
},
|
||||
onClick: (info) => {
|
||||
if (!info.object) return;
|
||||
const obj = info.object as DashSeg;
|
||||
if (obj.fromMmsi == null || obj.toMmsi == null) return;
|
||||
const sourceEvent = (info as { srcEvent?: { shiftKey?: boolean; ctrlKey?: boolean; metaKey?: boolean } | null }).srcEvent;
|
||||
if (sourceEvent && hasAuxiliarySelectModifier(sourceEvent)) {
|
||||
onToggleHighlightMmsi?.(obj.fromMmsi);
|
||||
onToggleHighlightMmsi?.(obj.toMmsi);
|
||||
return;
|
||||
}
|
||||
onDeckSelectOrHighlight({ mmsi: obj.fromMmsi });
|
||||
},
|
||||
}),
|
||||
);
|
||||
}
|
||||
|
||||
if (overlays.fleetCircles && (fleetCircles?.length ?? 0) > 0) {
|
||||
layers.push(
|
||||
new ScatterplotLayer<FleetCircle>({
|
||||
id: 'fleet-circles',
|
||||
data: fleetCircles,
|
||||
pickable: true,
|
||||
billboard: false,
|
||||
parameters: overlayParams,
|
||||
filled: false,
|
||||
stroked: true,
|
||||
radiusUnits: 'meters',
|
||||
getRadius: (d) => d.radiusNm * 1852,
|
||||
lineWidthUnits: 'pixels',
|
||||
getLineWidth: () => 1.1,
|
||||
getLineColor: () => FLEET_RANGE_LINE_DECK,
|
||||
getPosition: (d) => d.center,
|
||||
onHover: (info) => {
|
||||
if (!info.object) { clearDeckHover(); return; }
|
||||
touchDeckHoverState(true);
|
||||
const obj = info.object as FleetCircle;
|
||||
const list = toFleetMmsiList(obj.vesselMmsis);
|
||||
setMapFleetHoverState(obj.ownerKey || null, list);
|
||||
setDeckHoverMmsi(list);
|
||||
clearDeckHoverPairs();
|
||||
},
|
||||
onClick: (info) => {
|
||||
if (!info.object) return;
|
||||
const obj = info.object as FleetCircle;
|
||||
const list = toFleetMmsiList(obj.vesselMmsis);
|
||||
const sourceEvent = (info as { srcEvent?: { shiftKey?: boolean; ctrlKey?: boolean; metaKey?: boolean } | null }).srcEvent;
|
||||
if (sourceEvent && hasAuxiliarySelectModifier(sourceEvent)) {
|
||||
for (const mmsi of list) onToggleHighlightMmsi?.(mmsi);
|
||||
return;
|
||||
}
|
||||
const first = list[0];
|
||||
if (first != null) onDeckSelectOrHighlight({ mmsi: first });
|
||||
},
|
||||
}),
|
||||
);
|
||||
layers.push(
|
||||
new ScatterplotLayer<FleetCircle>({
|
||||
id: 'fleet-circles-fill',
|
||||
data: fleetCircles,
|
||||
pickable: false,
|
||||
billboard: false,
|
||||
parameters: overlayParams,
|
||||
filled: true,
|
||||
stroked: false,
|
||||
radiusUnits: 'meters',
|
||||
getRadius: (d) => d.radiusNm * 1852,
|
||||
getFillColor: () => FLEET_RANGE_FILL_DECK,
|
||||
getPosition: (d) => d.center,
|
||||
}),
|
||||
);
|
||||
}
|
||||
|
||||
if (settings.showShips) {
|
||||
const shipOnHover = (info: PickingInfo) => {
|
||||
if (!info.object) { clearDeckHover(); return; }
|
||||
touchDeckHoverState(true);
|
||||
const obj = info.object as AisTarget;
|
||||
setDeckHoverMmsi([obj.mmsi]);
|
||||
clearDeckHoverPairs();
|
||||
clearMapFleetHoverState();
|
||||
};
|
||||
const shipOnClick = (info: PickingInfo) => {
|
||||
if (!info.object) { onSelectMmsi(null); return; }
|
||||
onDeckSelectOrHighlight(
|
||||
{
|
||||
mmsi: (info.object as AisTarget).mmsi,
|
||||
srcEvent: (info as { srcEvent?: { shiftKey?: boolean; ctrlKey?: boolean; metaKey?: boolean } | null }).srcEvent,
|
||||
},
|
||||
true,
|
||||
);
|
||||
};
|
||||
|
||||
if (shipOtherData.length > 0) {
|
||||
layers.push(
|
||||
new IconLayer<AisTarget>({
|
||||
id: 'ships-other',
|
||||
data: shipOtherData,
|
||||
pickable: true,
|
||||
billboard: false,
|
||||
parameters: overlayParams,
|
||||
iconAtlas: '/assets/ship.svg',
|
||||
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: () => FLAT_SHIP_ICON_SIZE,
|
||||
getColor: (d) => getShipColor(d, null, null, EMPTY_MMSI_SET),
|
||||
onHover: shipOnHover,
|
||||
onClick: shipOnClick,
|
||||
alphaCutoff: 0.05,
|
||||
}),
|
||||
);
|
||||
}
|
||||
|
||||
if (shipOverlayOtherData.length > 0) {
|
||||
layers.push(
|
||||
new IconLayer<AisTarget>({
|
||||
id: 'ships-overlay-other',
|
||||
data: shipOverlayOtherData,
|
||||
pickable: false,
|
||||
billboard: false,
|
||||
parameters: overlayParams,
|
||||
iconAtlas: '/assets/ship.svg',
|
||||
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 (selectedMmsi != null && d.mmsi === selectedMmsi) return FLAT_SHIP_ICON_SIZE_SELECTED;
|
||||
if (shipHighlightSet.has(d.mmsi)) return FLAT_SHIP_ICON_SIZE_HIGHLIGHTED;
|
||||
return 0;
|
||||
},
|
||||
getColor: (d) => getShipColor(d, selectedMmsi, null, shipHighlightSet),
|
||||
alphaCutoff: 0.05,
|
||||
}),
|
||||
);
|
||||
}
|
||||
|
||||
if (legacyTargetsOrdered.length > 0) {
|
||||
layers.push(
|
||||
new ScatterplotLayer<AisTarget>({
|
||||
id: 'legacy-halo',
|
||||
data: legacyTargetsOrdered,
|
||||
pickable: false,
|
||||
billboard: false,
|
||||
parameters: overlayParams,
|
||||
filled: false,
|
||||
stroked: true,
|
||||
radiusUnits: 'pixels',
|
||||
getRadius: () => FLAT_LEGACY_HALO_RADIUS,
|
||||
lineWidthUnits: 'pixels',
|
||||
getLineWidth: () => 2,
|
||||
getLineColor: () => HALO_OUTLINE_COLOR,
|
||||
getPosition: (d) => [d.lon, d.lat] as [number, number],
|
||||
}),
|
||||
);
|
||||
}
|
||||
|
||||
if (shipTargetData.length > 0) {
|
||||
layers.push(
|
||||
new IconLayer<AisTarget>({
|
||||
id: 'ships-target',
|
||||
data: shipTargetData,
|
||||
pickable: true,
|
||||
billboard: false,
|
||||
parameters: overlayParams,
|
||||
iconAtlas: '/assets/ship.svg',
|
||||
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: () => FLAT_SHIP_ICON_SIZE,
|
||||
getColor: (d) => getShipColor(d, null, legacyHits?.get(d.mmsi)?.shipCode ?? null, EMPTY_MMSI_SET),
|
||||
onHover: shipOnHover,
|
||||
onClick: shipOnClick,
|
||||
alphaCutoff: 0.05,
|
||||
}),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
if (overlays.pairRange && pairRangesInteractive.length > 0) {
|
||||
layers.push(new ScatterplotLayer<PairRangeCircle>({ id: 'pair-range-overlay', data: pairRangesInteractive, pickable: false, billboard: false, parameters: overlayParams, filled: false, stroked: true, radiusUnits: 'meters', getRadius: (d) => d.radiusNm * 1852, radiusMinPixels: 10, lineWidthUnits: 'pixels', getLineWidth: () => 2.2, getLineColor: (d) => (d.warn ? PAIR_RANGE_WARN_DECK_HL : PAIR_RANGE_NORMAL_DECK_HL), getPosition: (d) => d.center }));
|
||||
}
|
||||
if (overlays.pairLines && pairLinksInteractive.length > 0) {
|
||||
layers.push(new LineLayer<PairLink>({ id: 'pair-lines-overlay', data: 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: () => 2.6, widthUnits: 'pixels' }));
|
||||
}
|
||||
if (overlays.fcLines && fcLinesInteractive.length > 0) {
|
||||
layers.push(new LineLayer<DashSeg>({ id: 'fc-lines-overlay', data: 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: () => 1.9, widthUnits: 'pixels' }));
|
||||
}
|
||||
if (overlays.fleetCircles && fleetCirclesInteractive.length > 0) {
|
||||
layers.push(new ScatterplotLayer<FleetCircle>({ id: 'fleet-circles-overlay-fill', data: 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: fleetCirclesInteractive, pickable: false, billboard: false, parameters: overlayParams, filled: false, stroked: true, radiusUnits: 'meters', getRadius: (d) => d.radiusNm * 1852, lineWidthUnits: 'pixels', getLineWidth: () => 1.8, getLineColor: () => FLEET_RANGE_LINE_DECK_HL, getPosition: (d) => d.center }));
|
||||
}
|
||||
|
||||
if (settings.showShips && legacyOverlayTargets.length > 0) {
|
||||
layers.push(new ScatterplotLayer<AisTarget>({ id: 'legacy-halo-overlay', data: legacyOverlayTargets, pickable: false, billboard: false, parameters: overlayParams, filled: false, stroked: true, radiusUnits: 'pixels', getRadius: (d) => { if (selectedMmsi && d.mmsi === selectedMmsi) return FLAT_LEGACY_HALO_RADIUS_SELECTED; return FLAT_LEGACY_HALO_RADIUS_HIGHLIGHTED; }, lineWidthUnits: 'pixels', getLineWidth: (d) => (selectedMmsi && d.mmsi === selectedMmsi ? 2.5 : 2.2), getLineColor: (d) => { if (selectedMmsi && d.mmsi === selectedMmsi) return HALO_OUTLINE_COLOR_SELECTED; if (shipHighlightSet.has(d.mmsi)) return HALO_OUTLINE_COLOR_HIGHLIGHTED; return HALO_OUTLINE_COLOR; }, getPosition: (d) => [d.lon, d.lat] as [number, number] }));
|
||||
}
|
||||
|
||||
if (settings.showShips && shipOverlayLayerData.filter((t) => legacyHits?.has(t.mmsi)).length > 0) {
|
||||
const shipOverlayTargetData2 = shipOverlayLayerData.filter((t) => legacyHits?.has(t.mmsi));
|
||||
layers.push(new IconLayer<AisTarget>({ id: 'ships-overlay-target', data: shipOverlayTargetData2, pickable: false, billboard: false, parameters: overlayParams, iconAtlas: '/assets/ship.svg', 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 (selectedMmsi != null && d.mmsi === selectedMmsi) return FLAT_SHIP_ICON_SIZE_SELECTED; if (shipHighlightSet.has(d.mmsi)) return FLAT_SHIP_ICON_SIZE_HIGHLIGHTED; return 0; }, getColor: (d) => { if (!shipHighlightSet.has(d.mmsi) && !(selectedMmsi != null && d.mmsi === selectedMmsi)) return [0, 0, 0, 0]; return getShipColor(d, selectedMmsi, legacyHits?.get(d.mmsi)?.shipCode ?? null, shipHighlightSet); } }));
|
||||
}
|
||||
|
||||
const normalizedLayers = sanitizeDeckLayerList(layers);
|
||||
const deckProps = {
|
||||
layers: normalizedLayers,
|
||||
getTooltip: (info: PickingInfo) => {
|
||||
if (!info.object) return null;
|
||||
if (info.layer && info.layer.id === 'density') {
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
const o: any = info.object;
|
||||
const n = Array.isArray(o?.points) ? o.points.length : 0;
|
||||
return { text: `AIS density: ${n}` };
|
||||
}
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
const obj: any = info.object;
|
||||
if (typeof obj.mmsi === 'number') {
|
||||
return getShipTooltipHtml({ mmsi: obj.mmsi, targetByMmsi: shipByMmsi, legacyHits });
|
||||
}
|
||||
if (info.layer && info.layer.id === 'pair-lines') {
|
||||
const aMmsi = toSafeNumber(obj.aMmsi);
|
||||
const bMmsi = toSafeNumber(obj.bMmsi);
|
||||
if (aMmsi == null || bMmsi == null) return null;
|
||||
return getPairLinkTooltipHtml({ warn: !!obj.warn, distanceNm: toSafeNumber(obj.distanceNm), aMmsi, bMmsi, legacyHits, targetByMmsi: shipByMmsi });
|
||||
}
|
||||
if (info.layer && info.layer.id === 'fc-lines') {
|
||||
const fcMmsi = toSafeNumber(obj.fcMmsi);
|
||||
const otherMmsi = toSafeNumber(obj.otherMmsi);
|
||||
if (fcMmsi == null || otherMmsi == null) return null;
|
||||
return getFcLinkTooltipHtml({ suspicious: !!obj.suspicious, distanceNm: toSafeNumber(obj.distanceNm), fcMmsi, otherMmsi, legacyHits, targetByMmsi: shipByMmsi });
|
||||
}
|
||||
if (info.layer && info.layer.id === 'pair-range') {
|
||||
const aMmsi = toSafeNumber(obj.aMmsi);
|
||||
const bMmsi = toSafeNumber(obj.bMmsi);
|
||||
if (aMmsi == null || bMmsi == null) return null;
|
||||
return getRangeTooltipHtml({ warn: !!obj.warn, distanceNm: toSafeNumber(obj.distanceNm), aMmsi, bMmsi, legacyHits });
|
||||
}
|
||||
if (info.layer && info.layer.id === 'fleet-circles') {
|
||||
return getFleetCircleTooltipHtml({ ownerKey: String(obj.ownerKey ?? ''), ownerLabel: String(obj.ownerKey ?? ''), count: Number(obj.count ?? 0) });
|
||||
}
|
||||
return null;
|
||||
},
|
||||
onClick: (info: PickingInfo) => {
|
||||
if (!info.object) { onSelectMmsi(null); return; }
|
||||
if (info.layer && info.layer.id === 'density') return;
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
const obj: any = info.object;
|
||||
if (typeof obj.mmsi === 'number') {
|
||||
const t = obj as AisTarget;
|
||||
const sourceEvent = (info as { srcEvent?: { shiftKey?: boolean; ctrlKey?: boolean; metaKey?: boolean } | null }).srcEvent;
|
||||
if (sourceEvent && hasAuxiliarySelectModifier(sourceEvent)) {
|
||||
onToggleHighlightMmsi?.(t.mmsi);
|
||||
return;
|
||||
}
|
||||
onSelectMmsi(t.mmsi);
|
||||
const clickOpts = { center: [t.lon, t.lat] as [number, number], zoom: Math.max(map.getZoom(), 10), duration: 600 };
|
||||
if (projectionRef.current === 'globe') {
|
||||
map.flyTo(clickOpts);
|
||||
} else {
|
||||
map.easeTo(clickOpts);
|
||||
}
|
||||
}
|
||||
},
|
||||
};
|
||||
|
||||
try {
|
||||
deckTarget.setProps(deckProps as never);
|
||||
} catch (e) {
|
||||
console.error('Failed to apply base mercator deck props. Falling back to empty layer set.', e);
|
||||
try {
|
||||
deckTarget.setProps({ ...deckProps, layers: [] as unknown[] } as never);
|
||||
} catch {
|
||||
// Ignore secondary failure.
|
||||
}
|
||||
}
|
||||
}, [
|
||||
ensureMercatorOverlay,
|
||||
projection,
|
||||
shipLayerData,
|
||||
shipByMmsi,
|
||||
pairRanges,
|
||||
pairLinks,
|
||||
fcDashed,
|
||||
fleetCircles,
|
||||
legacyTargetsOrdered,
|
||||
legacyHits,
|
||||
legacyOverlayTargets,
|
||||
shipOverlayLayerData,
|
||||
pairRangesInteractive,
|
||||
pairLinksInteractive,
|
||||
fcLinesInteractive,
|
||||
fleetCirclesInteractive,
|
||||
overlays.pairRange,
|
||||
overlays.pairLines,
|
||||
overlays.fcLines,
|
||||
overlays.fleetCircles,
|
||||
settings.showDensity,
|
||||
settings.showShips,
|
||||
onDeckSelectOrHighlight,
|
||||
onSelectMmsi,
|
||||
onToggleHighlightMmsi,
|
||||
setDeckHoverPairs,
|
||||
clearMapFleetHoverState,
|
||||
setDeckHoverMmsi,
|
||||
clearDeckHoverMmsi,
|
||||
toFleetMmsiList,
|
||||
touchDeckHoverState,
|
||||
hasAuxiliarySelectModifier,
|
||||
]);
|
||||
|
||||
// Globe Deck overlay
|
||||
useEffect(() => {
|
||||
const map = mapRef.current;
|
||||
if (!map || projection !== 'globe' || projectionBusyRef.current) return;
|
||||
const deckTarget = globeDeckLayerRef.current;
|
||||
if (!deckTarget) return;
|
||||
|
||||
const overlayParams = GLOBE_OVERLAY_PARAMS;
|
||||
const globeLayers: unknown[] = [];
|
||||
|
||||
if (overlays.pairRange && pairRanges.length > 0) {
|
||||
globeLayers.push(new ScatterplotLayer<PairRangeCircle>({ id: 'pair-range-globe', data: pairRanges, pickable: true, billboard: false, parameters: overlayParams, filled: false, stroked: true, radiusUnits: 'meters', getRadius: (d) => d.radiusNm * 1852, radiusMinPixels: 10, lineWidthUnits: 'pixels', getLineWidth: (d) => (isHighlightedPair(d.aMmsi, d.bMmsi) ? 2.2 : 1), getLineColor: (d) => { const hl = isHighlightedPair(d.aMmsi, d.bMmsi); if (hl) return d.warn ? PAIR_RANGE_WARN_DECK_HL : PAIR_RANGE_NORMAL_DECK_HL; return d.warn ? PAIR_RANGE_WARN_DECK : PAIR_RANGE_NORMAL_DECK; }, getPosition: (d) => d.center, onHover: (info) => { if (!info.object) { clearDeckHoverPairs(); clearDeckHoverMmsi(); return; } touchDeckHoverState(true); const p = info.object as PairRangeCircle; setDeckHoverPairs([p.aMmsi, p.bMmsi]); setDeckHoverMmsi([p.aMmsi, p.bMmsi]); clearMapFleetHoverState(); } }));
|
||||
}
|
||||
|
||||
if (overlays.pairLines && (pairLinks?.length ?? 0) > 0) {
|
||||
const links = pairLinks || [];
|
||||
globeLayers.push(new LineLayer<PairLink>({ id: 'pair-lines-globe', data: links, pickable: true, parameters: overlayParams, getSourcePosition: (d) => d.from, getTargetPosition: (d) => d.to, getColor: (d) => { const hl = isHighlightedPair(d.aMmsi, d.bMmsi); if (hl) return d.warn ? PAIR_LINE_WARN_DECK_HL : PAIR_LINE_NORMAL_DECK_HL; return d.warn ? PAIR_LINE_WARN_DECK : PAIR_LINE_NORMAL_DECK; }, getWidth: (d) => (isHighlightedPair(d.aMmsi, d.bMmsi) ? 2.6 : d.warn ? 2.2 : 1.4), widthUnits: 'pixels', onHover: (info) => { if (!info.object) { clearDeckHoverPairs(); clearDeckHoverMmsi(); return; } touchDeckHoverState(true); const obj = info.object as PairLink; setDeckHoverPairs([obj.aMmsi, obj.bMmsi]); setDeckHoverMmsi([obj.aMmsi, obj.bMmsi]); clearMapFleetHoverState(); } }));
|
||||
}
|
||||
|
||||
if (overlays.fcLines && fcDashed.length > 0) {
|
||||
globeLayers.push(new LineLayer<DashSeg>({ id: 'fc-lines-globe', data: fcDashed, pickable: true, parameters: overlayParams, getSourcePosition: (d) => d.from, getTargetPosition: (d) => d.to, getColor: (d) => { const ih = [d.fromMmsi, d.toMmsi].some((v) => isHighlightedMmsi(v ?? -1)); if (ih) return d.suspicious ? FC_LINE_SUSPICIOUS_DECK_HL : FC_LINE_NORMAL_DECK_HL; return d.suspicious ? FC_LINE_SUSPICIOUS_DECK : FC_LINE_NORMAL_DECK; }, getWidth: (d) => { const ih = [d.fromMmsi, d.toMmsi].some((v) => isHighlightedMmsi(v ?? -1)); return ih ? 1.9 : 1.3; }, widthUnits: 'pixels', onHover: (info) => { if (!info.object) { clearDeckHoverPairs(); clearDeckHoverMmsi(); return; } touchDeckHoverState(true); const obj = info.object as DashSeg; if (obj.fromMmsi == null || obj.toMmsi == null) { clearDeckHoverPairs(); clearDeckHoverMmsi(); return; } setDeckHoverPairs([obj.fromMmsi, obj.toMmsi]); setDeckHoverMmsi([obj.fromMmsi, obj.toMmsi]); clearMapFleetHoverState(); } }));
|
||||
}
|
||||
|
||||
if (overlays.fleetCircles && (fleetCircles?.length ?? 0) > 0) {
|
||||
const circles = fleetCircles || [];
|
||||
globeLayers.push(new ScatterplotLayer<FleetCircle>({ id: 'fleet-circles-globe', data: circles, pickable: true, billboard: false, parameters: overlayParams, filled: false, stroked: true, radiusUnits: 'meters', getRadius: (d) => d.radiusNm * 1852, lineWidthUnits: 'pixels', getLineWidth: (d) => (isHighlightedFleet(d.ownerKey, d.vesselMmsis) ? 1.8 : 1.1), getLineColor: (d) => (isHighlightedFleet(d.ownerKey, d.vesselMmsis) ? FLEET_RANGE_LINE_DECK_HL : FLEET_RANGE_LINE_DECK), getPosition: (d) => d.center, onHover: (info) => { if (!info.object) { clearDeckHoverPairs(); clearDeckHoverMmsi(); clearMapFleetHoverState(); return; } touchDeckHoverState(true); const obj = info.object as FleetCircle; const list = toFleetMmsiList(obj.vesselMmsis); setMapFleetHoverState(obj.ownerKey || null, list); setDeckHoverMmsi(list); clearDeckHoverPairs(); } }));
|
||||
globeLayers.push(new ScatterplotLayer<FleetCircle>({ id: 'fleet-circles-fill-globe', data: circles, pickable: false, billboard: false, parameters: overlayParams, filled: true, stroked: false, radiusUnits: 'meters', getRadius: (d) => d.radiusNm * 1852, getFillColor: (d) => (isHighlightedFleet(d.ownerKey, d.vesselMmsis) ? FLEET_RANGE_FILL_DECK_HL : FLEET_RANGE_FILL_DECK), getPosition: (d) => d.center }));
|
||||
}
|
||||
|
||||
if (settings.showShips && legacyTargetsOrdered.length > 0) {
|
||||
globeLayers.push(new ScatterplotLayer<AisTarget>({ id: 'legacy-halo-globe', data: legacyTargetsOrdered, pickable: false, billboard: false, parameters: overlayParams, filled: false, stroked: true, radiusUnits: 'pixels', getRadius: (d) => { if (selectedMmsi && d.mmsi === selectedMmsi) return FLAT_LEGACY_HALO_RADIUS_SELECTED; return FLAT_LEGACY_HALO_RADIUS; }, lineWidthUnits: 'pixels', getLineWidth: (d) => (selectedMmsi && d.mmsi === selectedMmsi ? 2.5 : 2), getLineColor: (d) => { if (selectedMmsi && d.mmsi === selectedMmsi) return HALO_OUTLINE_COLOR_SELECTED; return HALO_OUTLINE_COLOR; }, getPosition: (d) => [d.lon, d.lat] as [number, number] }));
|
||||
}
|
||||
|
||||
const normalizedLayers = sanitizeDeckLayerList(globeLayers);
|
||||
const globeDeckProps = { layers: normalizedLayers, getTooltip: undefined, onClick: undefined };
|
||||
|
||||
try {
|
||||
deckTarget.setProps(globeDeckProps as never);
|
||||
} catch (e) {
|
||||
console.error('Failed to apply globe deck props. Falling back to empty deck layer set.', e);
|
||||
try {
|
||||
deckTarget.setProps({ ...globeDeckProps, layers: [] as unknown[] } as never);
|
||||
} catch {
|
||||
// Ignore secondary failure.
|
||||
}
|
||||
}
|
||||
}, [
|
||||
projection,
|
||||
pairRanges,
|
||||
pairLinks,
|
||||
fcDashed,
|
||||
fleetCircles,
|
||||
legacyTargetsOrdered,
|
||||
overlays.pairRange,
|
||||
overlays.pairLines,
|
||||
overlays.fcLines,
|
||||
overlays.fleetCircles,
|
||||
settings.showShips,
|
||||
selectedMmsi,
|
||||
isHighlightedFleet,
|
||||
isHighlightedPair,
|
||||
clearDeckHoverPairs,
|
||||
clearDeckHoverMmsi,
|
||||
clearMapFleetHoverState,
|
||||
setDeckHoverPairs,
|
||||
setDeckHoverMmsi,
|
||||
setMapFleetHoverState,
|
||||
toFleetMmsiList,
|
||||
touchDeckHoverState,
|
||||
legacyHits,
|
||||
]);
|
||||
}
|
||||
61
apps/web/src/widgets/map3d/hooks/useFlyTo.ts
Normal file
61
apps/web/src/widgets/map3d/hooks/useFlyTo.ts
Normal file
@ -0,0 +1,61 @@
|
||||
import { useEffect, type MutableRefObject } from 'react';
|
||||
import type maplibregl from 'maplibre-gl';
|
||||
import { onMapStyleReady } from '../lib/mapCore';
|
||||
import type { MapProjectionId } from '../types';
|
||||
|
||||
export function useFlyTo(
|
||||
mapRef: MutableRefObject<maplibregl.Map | null>,
|
||||
projectionRef: MutableRefObject<MapProjectionId>,
|
||||
opts: {
|
||||
selectedMmsi: number | null;
|
||||
shipData: { mmsi: number; lon: number; lat: number }[];
|
||||
fleetFocusId: string | number | undefined;
|
||||
fleetFocusLon: number | undefined;
|
||||
fleetFocusLat: number | undefined;
|
||||
fleetFocusZoom: number | undefined;
|
||||
},
|
||||
) {
|
||||
const { selectedMmsi, shipData, fleetFocusId, fleetFocusLon, fleetFocusLat, fleetFocusZoom } = opts;
|
||||
|
||||
useEffect(() => {
|
||||
const map = mapRef.current;
|
||||
if (!map) return;
|
||||
if (!selectedMmsi) return;
|
||||
const t = shipData.find((x) => x.mmsi === selectedMmsi);
|
||||
if (!t) return;
|
||||
const flyOpts = { center: [t.lon, t.lat] as [number, number], zoom: Math.max(map.getZoom(), 10), duration: 600 };
|
||||
if (projectionRef.current === 'globe') {
|
||||
map.flyTo(flyOpts);
|
||||
} else {
|
||||
map.easeTo(flyOpts);
|
||||
}
|
||||
}, [selectedMmsi, shipData]);
|
||||
|
||||
useEffect(() => {
|
||||
const map = mapRef.current;
|
||||
if (!map || fleetFocusLon == null || fleetFocusLat == null || !Number.isFinite(fleetFocusLon) || !Number.isFinite(fleetFocusLat))
|
||||
return;
|
||||
const lon = fleetFocusLon;
|
||||
const lat = fleetFocusLat;
|
||||
const zoom = fleetFocusZoom ?? 10;
|
||||
|
||||
const apply = () => {
|
||||
const flyOpts = { center: [lon, lat] as [number, number], zoom, duration: 700 };
|
||||
if (projectionRef.current === 'globe') {
|
||||
map.flyTo(flyOpts);
|
||||
} else {
|
||||
map.easeTo(flyOpts);
|
||||
}
|
||||
};
|
||||
|
||||
if (map.isStyleLoaded()) {
|
||||
apply();
|
||||
return;
|
||||
}
|
||||
|
||||
const stop = onMapStyleReady(map, apply);
|
||||
return () => {
|
||||
stop();
|
||||
};
|
||||
}, [fleetFocusId, fleetFocusLon, fleetFocusLat, fleetFocusZoom]);
|
||||
}
|
||||
318
apps/web/src/widgets/map3d/hooks/useGlobeInteraction.ts
Normal file
318
apps/web/src/widgets/map3d/hooks/useGlobeInteraction.ts
Normal file
@ -0,0 +1,318 @@
|
||||
import { useCallback, useEffect, useRef, type MutableRefObject } from 'react';
|
||||
import maplibregl from 'maplibre-gl';
|
||||
import type { AisTarget } from '../../../entities/aisTarget/model/types';
|
||||
import type { LegacyVesselInfo } from '../../../entities/legacyVessel/model/types';
|
||||
import type { MapToggleState } from '../../../features/mapToggles/MapToggles';
|
||||
import type { Map3DSettings, MapProjectionId } from '../types';
|
||||
import { toIntMmsi, toSafeNumber } from '../lib/setUtils';
|
||||
import {
|
||||
getShipTooltipHtml,
|
||||
getPairLinkTooltipHtml,
|
||||
getFcLinkTooltipHtml,
|
||||
getRangeTooltipHtml,
|
||||
getFleetCircleTooltipHtml,
|
||||
} from '../lib/tooltips';
|
||||
import { getZoneIdFromProps, getZoneDisplayNameFromProps } from '../lib/zoneUtils';
|
||||
|
||||
export function useGlobeInteraction(
|
||||
mapRef: MutableRefObject<maplibregl.Map | null>,
|
||||
projectionBusyRef: MutableRefObject<boolean>,
|
||||
opts: {
|
||||
projection: MapProjectionId;
|
||||
settings: Map3DSettings;
|
||||
overlays: MapToggleState;
|
||||
targets: AisTarget[];
|
||||
shipData: AisTarget[];
|
||||
shipByMmsi: Map<number, AisTarget>;
|
||||
selectedMmsi: number | null;
|
||||
hoveredZoneId: string | null;
|
||||
legacyHits: Map<number, LegacyVesselInfo> | null | undefined;
|
||||
clearDeckHoverPairs: () => void;
|
||||
clearDeckHoverMmsi: () => void;
|
||||
clearMapFleetHoverState: () => void;
|
||||
setDeckHoverPairs: (next: number[]) => void;
|
||||
setDeckHoverMmsi: (next: number[]) => void;
|
||||
setMapFleetHoverState: (ownerKey: string | null, vesselMmsis: number[]) => void;
|
||||
setHoveredZoneId: (updater: (prev: string | null) => string | null) => void;
|
||||
},
|
||||
) {
|
||||
const {
|
||||
projection, legacyHits, shipByMmsi,
|
||||
clearDeckHoverPairs, clearDeckHoverMmsi, clearMapFleetHoverState,
|
||||
setDeckHoverPairs, setDeckHoverMmsi, setMapFleetHoverState, setHoveredZoneId,
|
||||
} = opts;
|
||||
|
||||
const mapTooltipRef = useRef<maplibregl.Popup | null>(null);
|
||||
|
||||
const clearGlobeTooltip = useCallback(() => {
|
||||
if (!mapTooltipRef.current) return;
|
||||
try {
|
||||
mapTooltipRef.current.remove();
|
||||
} catch {
|
||||
// ignore
|
||||
}
|
||||
mapTooltipRef.current = null;
|
||||
}, []);
|
||||
|
||||
// eslint-disable-next-line react-hooks/preserve-manual-memoization
|
||||
const setGlobeTooltip = useCallback((lngLat: maplibregl.LngLatLike, tooltipHtml: string) => {
|
||||
const map = mapRef.current;
|
||||
if (!map || !map.isStyleLoaded()) return;
|
||||
if (!mapTooltipRef.current) {
|
||||
mapTooltipRef.current = new maplibregl.Popup({
|
||||
closeButton: false,
|
||||
closeOnClick: false,
|
||||
maxWidth: '360px',
|
||||
className: 'maplibre-tooltip-popup',
|
||||
});
|
||||
}
|
||||
|
||||
const container = document.createElement('div');
|
||||
container.className = 'maplibre-tooltip-popup__content';
|
||||
container.innerHTML = tooltipHtml;
|
||||
|
||||
mapTooltipRef.current.setLngLat(lngLat).setDOMContent(container).addTo(map);
|
||||
}, []);
|
||||
|
||||
const buildGlobeFeatureTooltip = useCallback(
|
||||
(feature: { properties?: Record<string, unknown> | null; layer?: { id?: string } } | null | undefined) => {
|
||||
if (!feature) return null;
|
||||
const props = feature.properties || {};
|
||||
const layerId = feature.layer?.id;
|
||||
|
||||
const maybeMmsi = toIntMmsi(props.mmsi);
|
||||
if (maybeMmsi != null && maybeMmsi > 0) {
|
||||
return getShipTooltipHtml({ mmsi: maybeMmsi, targetByMmsi: shipByMmsi, legacyHits });
|
||||
}
|
||||
|
||||
if (layerId === 'pair-lines-ml') {
|
||||
const warn = props.warn === true;
|
||||
const aMmsi = toIntMmsi(props.aMmsi);
|
||||
const bMmsi = toIntMmsi(props.bMmsi);
|
||||
if (aMmsi == null || bMmsi == null) return null;
|
||||
return getPairLinkTooltipHtml({
|
||||
warn, distanceNm: toSafeNumber(props.distanceNm),
|
||||
aMmsi, bMmsi, legacyHits, targetByMmsi: shipByMmsi,
|
||||
});
|
||||
}
|
||||
|
||||
if (layerId === 'fc-lines-ml') {
|
||||
const fcMmsi = toIntMmsi(props.fcMmsi);
|
||||
const otherMmsi = toIntMmsi(props.otherMmsi);
|
||||
if (fcMmsi == null || otherMmsi == null) return null;
|
||||
return getFcLinkTooltipHtml({
|
||||
suspicious: props.suspicious === true,
|
||||
distanceNm: toSafeNumber(props.distanceNm),
|
||||
fcMmsi, otherMmsi, legacyHits, targetByMmsi: shipByMmsi,
|
||||
});
|
||||
}
|
||||
|
||||
if (layerId === 'pair-range-ml') {
|
||||
const aMmsi = toIntMmsi(props.aMmsi);
|
||||
const bMmsi = toIntMmsi(props.bMmsi);
|
||||
if (aMmsi == null || bMmsi == null) return null;
|
||||
return getRangeTooltipHtml({
|
||||
warn: props.warn === true,
|
||||
distanceNm: toSafeNumber(props.distanceNm),
|
||||
aMmsi, bMmsi, legacyHits,
|
||||
});
|
||||
}
|
||||
|
||||
if (layerId === 'fleet-circles-ml' || layerId === 'fleet-circles-ml-fill') {
|
||||
return getFleetCircleTooltipHtml({
|
||||
ownerKey: String(props.ownerKey ?? ''),
|
||||
ownerLabel: String(props.ownerLabel ?? props.ownerKey ?? ''),
|
||||
count: Number(props.count ?? 0),
|
||||
});
|
||||
}
|
||||
|
||||
const zoneLabel = getZoneDisplayNameFromProps(props);
|
||||
if (zoneLabel) {
|
||||
return { html: `<div style="font-size: 12px; font-family: system-ui;">${zoneLabel}</div>` };
|
||||
}
|
||||
|
||||
return null;
|
||||
},
|
||||
[legacyHits, shipByMmsi],
|
||||
);
|
||||
|
||||
useEffect(() => {
|
||||
const map = mapRef.current;
|
||||
if (!map) return;
|
||||
|
||||
const clearDeckGlobeHoverState = () => {
|
||||
clearDeckHoverMmsi();
|
||||
clearDeckHoverPairs();
|
||||
setHoveredZoneId((prev) => (prev === null ? prev : null));
|
||||
clearMapFleetHoverState();
|
||||
};
|
||||
|
||||
const resetGlobeHoverStates = () => {
|
||||
clearDeckHoverMmsi();
|
||||
clearDeckHoverPairs();
|
||||
setHoveredZoneId((prev) => (prev === null ? prev : null));
|
||||
clearMapFleetHoverState();
|
||||
};
|
||||
|
||||
const normalizeMmsiList = (value: unknown): number[] => {
|
||||
if (!Array.isArray(value)) return [];
|
||||
const out: number[] = [];
|
||||
for (const n of value) {
|
||||
const m = toIntMmsi(n);
|
||||
if (m != null) out.push(m);
|
||||
}
|
||||
return out;
|
||||
};
|
||||
|
||||
const onMouseMove = (e: maplibregl.MapMouseEvent) => {
|
||||
if (projection !== 'globe') {
|
||||
clearGlobeTooltip();
|
||||
resetGlobeHoverStates();
|
||||
return;
|
||||
}
|
||||
if (projectionBusyRef.current) {
|
||||
resetGlobeHoverStates();
|
||||
clearGlobeTooltip();
|
||||
return;
|
||||
}
|
||||
if (!map.isStyleLoaded()) {
|
||||
clearDeckGlobeHoverState();
|
||||
clearGlobeTooltip();
|
||||
return;
|
||||
}
|
||||
|
||||
let candidateLayerIds: string[] = [];
|
||||
try {
|
||||
candidateLayerIds = [
|
||||
'ships-globe', 'ships-globe-halo', 'ships-globe-outline',
|
||||
'pair-lines-ml', 'fc-lines-ml',
|
||||
'fleet-circles-ml', 'fleet-circles-ml-fill',
|
||||
'pair-range-ml',
|
||||
'zones-fill', 'zones-line', 'zones-label',
|
||||
].filter((id) => map.getLayer(id));
|
||||
} catch {
|
||||
candidateLayerIds = [];
|
||||
}
|
||||
|
||||
if (candidateLayerIds.length === 0) {
|
||||
resetGlobeHoverStates();
|
||||
clearGlobeTooltip();
|
||||
return;
|
||||
}
|
||||
|
||||
let rendered: Array<{ properties?: Record<string, unknown> | null; layer?: { id?: string } }> = [];
|
||||
try {
|
||||
rendered = map.queryRenderedFeatures(e.point, { layers: candidateLayerIds }) as unknown as Array<{
|
||||
properties?: Record<string, unknown> | null;
|
||||
layer?: { id?: string };
|
||||
}>;
|
||||
} catch {
|
||||
rendered = [];
|
||||
}
|
||||
|
||||
const priority = [
|
||||
'ships-globe', 'ships-globe-halo', 'ships-globe-outline',
|
||||
'pair-lines-ml', 'fc-lines-ml', 'pair-range-ml',
|
||||
'fleet-circles-ml-fill', 'fleet-circles-ml',
|
||||
'zones-fill', 'zones-line', 'zones-label',
|
||||
];
|
||||
|
||||
const first = priority.map((id) => rendered.find((r) => r.layer?.id === id)).find(Boolean) as
|
||||
| { properties?: Record<string, unknown> | null; layer?: { id?: string } }
|
||||
| undefined;
|
||||
|
||||
if (!first) {
|
||||
resetGlobeHoverStates();
|
||||
clearGlobeTooltip();
|
||||
return;
|
||||
}
|
||||
|
||||
const layerId = first.layer?.id;
|
||||
const props = first.properties || {};
|
||||
const isShipLayer = layerId === 'ships-globe' || layerId === 'ships-globe-halo' || layerId === 'ships-globe-outline';
|
||||
const isPairLayer = layerId === 'pair-lines-ml' || layerId === 'pair-range-ml';
|
||||
const isFcLayer = layerId === 'fc-lines-ml';
|
||||
const isFleetLayer = layerId === 'fleet-circles-ml' || layerId === 'fleet-circles-ml-fill';
|
||||
const isZoneLayer = layerId === 'zones-fill' || layerId === 'zones-line' || layerId === 'zones-label';
|
||||
|
||||
if (isShipLayer) {
|
||||
const mmsi = toIntMmsi(props.mmsi);
|
||||
setDeckHoverMmsi(mmsi == null ? [] : [mmsi]);
|
||||
clearDeckHoverPairs();
|
||||
clearMapFleetHoverState();
|
||||
setHoveredZoneId((prev) => (prev === null ? prev : null));
|
||||
} else if (isPairLayer) {
|
||||
const aMmsi = toIntMmsi(props.aMmsi);
|
||||
const bMmsi = toIntMmsi(props.bMmsi);
|
||||
setDeckHoverPairs([...(aMmsi == null ? [] : [aMmsi]), ...(bMmsi == null ? [] : [bMmsi])]);
|
||||
clearDeckHoverMmsi();
|
||||
clearMapFleetHoverState();
|
||||
setHoveredZoneId((prev) => (prev === null ? prev : null));
|
||||
} else if (isFcLayer) {
|
||||
const from = toIntMmsi(props.fcMmsi);
|
||||
const to = toIntMmsi(props.otherMmsi);
|
||||
const fromTo = [from, to].filter((v): v is number => v != null);
|
||||
setDeckHoverPairs(fromTo);
|
||||
setDeckHoverMmsi(fromTo);
|
||||
clearMapFleetHoverState();
|
||||
setHoveredZoneId((prev) => (prev === null ? prev : null));
|
||||
} else if (isFleetLayer) {
|
||||
const ownerKey = String(props.ownerKey ?? '');
|
||||
const list = normalizeMmsiList(props.vesselMmsis);
|
||||
setMapFleetHoverState(ownerKey || null, list);
|
||||
clearDeckHoverMmsi();
|
||||
clearDeckHoverPairs();
|
||||
setHoveredZoneId((prev) => (prev === null ? prev : null));
|
||||
} else if (isZoneLayer) {
|
||||
clearMapFleetHoverState();
|
||||
clearDeckHoverMmsi();
|
||||
clearDeckHoverPairs();
|
||||
const zoneId = getZoneIdFromProps(props);
|
||||
setHoveredZoneId(() => zoneId || null);
|
||||
} else {
|
||||
resetGlobeHoverStates();
|
||||
}
|
||||
|
||||
const tooltip = buildGlobeFeatureTooltip(first);
|
||||
if (!tooltip) {
|
||||
if (!isZoneLayer) {
|
||||
resetGlobeHoverStates();
|
||||
}
|
||||
clearGlobeTooltip();
|
||||
return;
|
||||
}
|
||||
|
||||
const content = tooltip?.html ?? '';
|
||||
if (content) {
|
||||
setGlobeTooltip(e.lngLat, content);
|
||||
return;
|
||||
}
|
||||
clearGlobeTooltip();
|
||||
};
|
||||
|
||||
const onMouseOut = () => {
|
||||
resetGlobeHoverStates();
|
||||
clearGlobeTooltip();
|
||||
};
|
||||
|
||||
map.on('mousemove', onMouseMove);
|
||||
map.on('mouseout', onMouseOut);
|
||||
|
||||
return () => {
|
||||
map.off('mousemove', onMouseMove);
|
||||
map.off('mouseout', onMouseOut);
|
||||
clearGlobeTooltip();
|
||||
};
|
||||
}, [
|
||||
projection,
|
||||
buildGlobeFeatureTooltip,
|
||||
clearGlobeTooltip,
|
||||
clearMapFleetHoverState,
|
||||
clearDeckHoverPairs,
|
||||
clearDeckHoverMmsi,
|
||||
setDeckHoverPairs,
|
||||
setDeckHoverMmsi,
|
||||
setMapFleetHoverState,
|
||||
setGlobeTooltip,
|
||||
]);
|
||||
}
|
||||
618
apps/web/src/widgets/map3d/hooks/useGlobeOverlays.ts
Normal file
618
apps/web/src/widgets/map3d/hooks/useGlobeOverlays.ts
Normal file
@ -0,0 +1,618 @@
|
||||
import { useCallback, useEffect, type MutableRefObject } from 'react';
|
||||
import type maplibregl from 'maplibre-gl';
|
||||
import type { GeoJSONSource, GeoJSONSourceSpecification, LayerSpecification } from 'maplibre-gl';
|
||||
import type { FcLink, FleetCircle, PairLink } from '../../../features/legacyDashboard/model/types';
|
||||
import type { MapToggleState } from '../../../features/mapToggles/MapToggles';
|
||||
import type { DashSeg, MapProjectionId, PairRangeCircle } from '../types';
|
||||
import {
|
||||
PAIR_LINE_NORMAL_ML, PAIR_LINE_WARN_ML,
|
||||
PAIR_LINE_NORMAL_ML_HL, PAIR_LINE_WARN_ML_HL,
|
||||
PAIR_RANGE_NORMAL_ML, PAIR_RANGE_WARN_ML,
|
||||
PAIR_RANGE_NORMAL_ML_HL, PAIR_RANGE_WARN_ML_HL,
|
||||
FC_LINE_NORMAL_ML, FC_LINE_SUSPICIOUS_ML,
|
||||
FC_LINE_NORMAL_ML_HL, FC_LINE_SUSPICIOUS_ML_HL,
|
||||
FLEET_FILL_ML, FLEET_FILL_ML_HL,
|
||||
FLEET_LINE_ML, FLEET_LINE_ML_HL,
|
||||
} from '../constants';
|
||||
import { makeUniqueSorted } from '../lib/setUtils';
|
||||
import {
|
||||
makePairLinkFeatureId,
|
||||
makeFcSegmentFeatureId,
|
||||
makeFleetCircleFeatureId,
|
||||
} from '../lib/featureIds';
|
||||
import {
|
||||
makeMmsiPairHighlightExpr,
|
||||
makeMmsiAnyEndpointExpr,
|
||||
makeFleetOwnerMatchExpr,
|
||||
makeFleetMemberMatchExpr,
|
||||
} from '../lib/mlExpressions';
|
||||
import { kickRepaint, onMapStyleReady } from '../lib/mapCore';
|
||||
import { circleRingLngLat } from '../lib/geometry';
|
||||
import { dashifyLine } from '../lib/dashifyLine';
|
||||
|
||||
export function useGlobeOverlays(
|
||||
mapRef: MutableRefObject<maplibregl.Map | null>,
|
||||
projectionBusyRef: MutableRefObject<boolean>,
|
||||
reorderGlobeFeatureLayers: () => void,
|
||||
opts: {
|
||||
overlays: MapToggleState;
|
||||
pairLinks: PairLink[] | undefined;
|
||||
fcLinks: FcLink[] | undefined;
|
||||
fleetCircles: FleetCircle[] | undefined;
|
||||
projection: MapProjectionId;
|
||||
mapSyncEpoch: number;
|
||||
hoveredFleetMmsiList: number[];
|
||||
hoveredFleetOwnerKeyList: string[];
|
||||
hoveredPairMmsiList: number[];
|
||||
},
|
||||
) {
|
||||
const {
|
||||
overlays, pairLinks, fcLinks, fleetCircles, projection, mapSyncEpoch,
|
||||
hoveredFleetMmsiList, hoveredFleetOwnerKeyList, hoveredPairMmsiList,
|
||||
} = opts;
|
||||
|
||||
// Pair lines
|
||||
useEffect(() => {
|
||||
const map = mapRef.current;
|
||||
if (!map) return;
|
||||
|
||||
const srcId = 'pair-lines-ml-src';
|
||||
const layerId = 'pair-lines-ml';
|
||||
|
||||
const remove = () => {
|
||||
try {
|
||||
if (map.getLayer(layerId)) map.setLayoutProperty(layerId, 'visibility', 'none');
|
||||
} catch {
|
||||
// ignore
|
||||
}
|
||||
};
|
||||
|
||||
const ensure = () => {
|
||||
if (projectionBusyRef.current) return;
|
||||
if (!map.isStyleLoaded()) return;
|
||||
if (projection !== 'globe' || !overlays.pairLines || (pairLinks?.length ?? 0) === 0) {
|
||||
remove();
|
||||
return;
|
||||
}
|
||||
|
||||
const fc: GeoJSON.FeatureCollection<GeoJSON.LineString> = {
|
||||
type: 'FeatureCollection',
|
||||
features: (pairLinks || []).map((p) => ({
|
||||
type: 'Feature',
|
||||
id: makePairLinkFeatureId(p.aMmsi, p.bMmsi),
|
||||
geometry: { type: 'LineString', coordinates: [p.from, p.to] },
|
||||
properties: {
|
||||
type: 'pair',
|
||||
aMmsi: p.aMmsi,
|
||||
bMmsi: p.bMmsi,
|
||||
distanceNm: p.distanceNm,
|
||||
warn: p.warn,
|
||||
},
|
||||
})),
|
||||
};
|
||||
|
||||
try {
|
||||
const existing = map.getSource(srcId) as GeoJSONSource | undefined;
|
||||
if (existing) existing.setData(fc);
|
||||
else map.addSource(srcId, { type: 'geojson', data: fc } as GeoJSONSourceSpecification);
|
||||
} catch (e) {
|
||||
console.warn('Pair lines source setup failed:', e);
|
||||
return;
|
||||
}
|
||||
|
||||
if (!map.getLayer(layerId)) {
|
||||
try {
|
||||
map.addLayer(
|
||||
{
|
||||
id: layerId,
|
||||
type: 'line',
|
||||
source: srcId,
|
||||
layout: { 'line-cap': 'round', 'line-join': 'round', visibility: 'visible' },
|
||||
paint: {
|
||||
'line-color': [
|
||||
'case',
|
||||
['==', ['get', 'highlighted'], 1],
|
||||
['case', ['boolean', ['get', 'warn'], false], PAIR_LINE_WARN_ML_HL, PAIR_LINE_NORMAL_ML_HL],
|
||||
['boolean', ['get', 'warn'], false],
|
||||
PAIR_LINE_WARN_ML,
|
||||
PAIR_LINE_NORMAL_ML,
|
||||
] as never,
|
||||
'line-width': [
|
||||
'case',
|
||||
['==', ['get', 'highlighted'], 1], 2.8,
|
||||
['boolean', ['get', 'warn'], false], 2.2,
|
||||
1.4,
|
||||
] as never,
|
||||
'line-opacity': 0.9,
|
||||
},
|
||||
} as unknown as LayerSpecification,
|
||||
undefined,
|
||||
);
|
||||
} catch (e) {
|
||||
console.warn('Pair lines layer add failed:', e);
|
||||
}
|
||||
} else {
|
||||
try {
|
||||
map.setLayoutProperty(layerId, 'visibility', 'visible');
|
||||
} catch {
|
||||
// ignore
|
||||
}
|
||||
}
|
||||
|
||||
reorderGlobeFeatureLayers();
|
||||
kickRepaint(map);
|
||||
};
|
||||
|
||||
const stop = onMapStyleReady(map, ensure);
|
||||
ensure();
|
||||
return () => {
|
||||
stop();
|
||||
};
|
||||
}, [projection, overlays.pairLines, pairLinks, mapSyncEpoch, reorderGlobeFeatureLayers]);
|
||||
|
||||
// FC lines
|
||||
useEffect(() => {
|
||||
const map = mapRef.current;
|
||||
if (!map) return;
|
||||
|
||||
const srcId = 'fc-lines-ml-src';
|
||||
const layerId = 'fc-lines-ml';
|
||||
|
||||
const remove = () => {
|
||||
try {
|
||||
if (map.getLayer(layerId)) map.setLayoutProperty(layerId, 'visibility', 'none');
|
||||
} catch {
|
||||
// ignore
|
||||
}
|
||||
};
|
||||
|
||||
const ensure = () => {
|
||||
if (projectionBusyRef.current) return;
|
||||
if (!map.isStyleLoaded()) return;
|
||||
if (projection !== 'globe' || !overlays.fcLines) {
|
||||
remove();
|
||||
return;
|
||||
}
|
||||
|
||||
const segs: DashSeg[] = [];
|
||||
for (const l of fcLinks || []) {
|
||||
segs.push(...dashifyLine(l.from, l.to, l.suspicious, l.distanceNm, l.fcMmsi, l.otherMmsi));
|
||||
}
|
||||
if (segs.length === 0) {
|
||||
remove();
|
||||
return;
|
||||
}
|
||||
|
||||
const fc: GeoJSON.FeatureCollection<GeoJSON.LineString> = {
|
||||
type: 'FeatureCollection',
|
||||
features: segs.map((s, idx) => ({
|
||||
type: 'Feature',
|
||||
id: makeFcSegmentFeatureId(s.fromMmsi ?? -1, s.toMmsi ?? -1, idx),
|
||||
geometry: { type: 'LineString', coordinates: [s.from, s.to] },
|
||||
properties: {
|
||||
type: 'fc',
|
||||
suspicious: s.suspicious,
|
||||
distanceNm: s.distanceNm,
|
||||
fcMmsi: s.fromMmsi ?? -1,
|
||||
otherMmsi: s.toMmsi ?? -1,
|
||||
},
|
||||
})),
|
||||
};
|
||||
|
||||
try {
|
||||
const existing = map.getSource(srcId) as GeoJSONSource | undefined;
|
||||
if (existing) existing.setData(fc);
|
||||
else map.addSource(srcId, { type: 'geojson', data: fc } as GeoJSONSourceSpecification);
|
||||
} catch (e) {
|
||||
console.warn('FC lines source setup failed:', e);
|
||||
return;
|
||||
}
|
||||
|
||||
if (!map.getLayer(layerId)) {
|
||||
try {
|
||||
map.addLayer(
|
||||
{
|
||||
id: layerId,
|
||||
type: 'line',
|
||||
source: srcId,
|
||||
layout: { 'line-cap': 'round', 'line-join': 'round', visibility: 'visible' },
|
||||
paint: {
|
||||
'line-color': [
|
||||
'case',
|
||||
['==', ['get', 'highlighted'], 1],
|
||||
['case', ['boolean', ['get', 'suspicious'], false], FC_LINE_SUSPICIOUS_ML_HL, FC_LINE_NORMAL_ML_HL],
|
||||
['boolean', ['get', 'suspicious'], false],
|
||||
FC_LINE_SUSPICIOUS_ML,
|
||||
FC_LINE_NORMAL_ML,
|
||||
] as never,
|
||||
'line-width': ['case', ['==', ['get', 'highlighted'], 1], 2.0, 1.3] as never,
|
||||
'line-opacity': 0.9,
|
||||
},
|
||||
} as unknown as LayerSpecification,
|
||||
undefined,
|
||||
);
|
||||
} catch (e) {
|
||||
console.warn('FC lines layer add failed:', e);
|
||||
}
|
||||
} else {
|
||||
try {
|
||||
map.setLayoutProperty(layerId, 'visibility', 'visible');
|
||||
} catch {
|
||||
// ignore
|
||||
}
|
||||
}
|
||||
|
||||
reorderGlobeFeatureLayers();
|
||||
kickRepaint(map);
|
||||
};
|
||||
|
||||
const stop = onMapStyleReady(map, ensure);
|
||||
ensure();
|
||||
return () => {
|
||||
stop();
|
||||
};
|
||||
}, [projection, overlays.fcLines, fcLinks, mapSyncEpoch, reorderGlobeFeatureLayers]);
|
||||
|
||||
// Fleet circles
|
||||
useEffect(() => {
|
||||
const map = mapRef.current;
|
||||
if (!map) return;
|
||||
|
||||
const srcId = 'fleet-circles-ml-src';
|
||||
const fillSrcId = 'fleet-circles-ml-fill-src';
|
||||
const layerId = 'fleet-circles-ml';
|
||||
const fillLayerId = 'fleet-circles-ml-fill';
|
||||
|
||||
const remove = () => {
|
||||
try {
|
||||
if (map.getLayer(fillLayerId)) map.setLayoutProperty(fillLayerId, 'visibility', 'none');
|
||||
} catch {
|
||||
// ignore
|
||||
}
|
||||
try {
|
||||
if (map.getLayer(layerId)) map.setLayoutProperty(layerId, 'visibility', 'none');
|
||||
} catch {
|
||||
// ignore
|
||||
}
|
||||
};
|
||||
|
||||
const ensure = () => {
|
||||
if (projectionBusyRef.current) return;
|
||||
if (!map.isStyleLoaded()) return;
|
||||
if (projection !== 'globe' || !overlays.fleetCircles || (fleetCircles?.length ?? 0) === 0) {
|
||||
remove();
|
||||
return;
|
||||
}
|
||||
|
||||
const fcLine: GeoJSON.FeatureCollection<GeoJSON.LineString> = {
|
||||
type: 'FeatureCollection',
|
||||
features: (fleetCircles || []).map((c) => {
|
||||
const ring = circleRingLngLat(c.center, c.radiusNm * 1852);
|
||||
return {
|
||||
type: 'Feature',
|
||||
id: makeFleetCircleFeatureId(c.ownerKey),
|
||||
geometry: { type: 'LineString', coordinates: ring },
|
||||
properties: {
|
||||
type: 'fleet',
|
||||
ownerKey: c.ownerKey,
|
||||
ownerLabel: c.ownerLabel,
|
||||
count: c.count,
|
||||
vesselMmsis: c.vesselMmsis,
|
||||
highlighted: 0,
|
||||
},
|
||||
};
|
||||
}),
|
||||
};
|
||||
|
||||
const fcFill: GeoJSON.FeatureCollection<GeoJSON.Polygon> = {
|
||||
type: 'FeatureCollection',
|
||||
features: (fleetCircles || []).map((c) => {
|
||||
const ring = circleRingLngLat(c.center, c.radiusNm * 1852);
|
||||
return {
|
||||
type: 'Feature',
|
||||
id: `${makeFleetCircleFeatureId(c.ownerKey)}-fill`,
|
||||
geometry: { type: 'Polygon', coordinates: [ring] },
|
||||
properties: {
|
||||
type: 'fleet-fill',
|
||||
ownerKey: c.ownerKey,
|
||||
ownerLabel: c.ownerLabel,
|
||||
count: c.count,
|
||||
vesselMmsis: c.vesselMmsis,
|
||||
highlighted: 0,
|
||||
},
|
||||
};
|
||||
}),
|
||||
};
|
||||
|
||||
try {
|
||||
const existing = map.getSource(srcId) as GeoJSONSource | undefined;
|
||||
if (existing) existing.setData(fcLine);
|
||||
else map.addSource(srcId, { type: 'geojson', data: fcLine } as GeoJSONSourceSpecification);
|
||||
} catch (e) {
|
||||
console.warn('Fleet circles source setup failed:', e);
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
const existingFill = map.getSource(fillSrcId) as GeoJSONSource | undefined;
|
||||
if (existingFill) existingFill.setData(fcFill);
|
||||
else map.addSource(fillSrcId, { type: 'geojson', data: fcFill } as GeoJSONSourceSpecification);
|
||||
} catch (e) {
|
||||
console.warn('Fleet circles source setup failed:', e);
|
||||
return;
|
||||
}
|
||||
|
||||
if (!map.getLayer(fillLayerId)) {
|
||||
try {
|
||||
map.addLayer(
|
||||
{
|
||||
id: fillLayerId,
|
||||
type: 'fill',
|
||||
source: fillSrcId,
|
||||
layout: { visibility: 'visible' },
|
||||
paint: {
|
||||
'fill-color': ['case', ['==', ['get', 'highlighted'], 1], FLEET_FILL_ML_HL, FLEET_FILL_ML] as never,
|
||||
'fill-opacity': ['case', ['==', ['get', 'highlighted'], 1], 0.7, 0.36] as never,
|
||||
},
|
||||
} as unknown as LayerSpecification,
|
||||
undefined,
|
||||
);
|
||||
} catch (e) {
|
||||
console.warn('Fleet circles fill layer add failed:', e);
|
||||
}
|
||||
} else {
|
||||
try {
|
||||
map.setLayoutProperty(fillLayerId, 'visibility', 'visible');
|
||||
} catch {
|
||||
// ignore
|
||||
}
|
||||
}
|
||||
|
||||
if (!map.getLayer(layerId)) {
|
||||
try {
|
||||
map.addLayer(
|
||||
{
|
||||
id: layerId,
|
||||
type: 'line',
|
||||
source: srcId,
|
||||
layout: { 'line-cap': 'round', 'line-join': 'round', visibility: 'visible' },
|
||||
paint: {
|
||||
'line-color': ['case', ['==', ['get', 'highlighted'], 1], FLEET_LINE_ML_HL, FLEET_LINE_ML] as never,
|
||||
'line-width': ['case', ['==', ['get', 'highlighted'], 1], 2, 1.1] as never,
|
||||
'line-opacity': 0.85,
|
||||
},
|
||||
} as unknown as LayerSpecification,
|
||||
undefined,
|
||||
);
|
||||
} catch (e) {
|
||||
console.warn('Fleet circles layer add failed:', e);
|
||||
}
|
||||
} else {
|
||||
try {
|
||||
map.setLayoutProperty(layerId, 'visibility', 'visible');
|
||||
} catch {
|
||||
// ignore
|
||||
}
|
||||
}
|
||||
|
||||
reorderGlobeFeatureLayers();
|
||||
kickRepaint(map);
|
||||
};
|
||||
|
||||
const stop = onMapStyleReady(map, ensure);
|
||||
ensure();
|
||||
return () => {
|
||||
stop();
|
||||
};
|
||||
}, [projection, overlays.fleetCircles, fleetCircles, mapSyncEpoch, reorderGlobeFeatureLayers]);
|
||||
|
||||
// Pair range
|
||||
useEffect(() => {
|
||||
const map = mapRef.current;
|
||||
if (!map) return;
|
||||
|
||||
const srcId = 'pair-range-ml-src';
|
||||
const layerId = 'pair-range-ml';
|
||||
|
||||
const remove = () => {
|
||||
try {
|
||||
if (map.getLayer(layerId)) map.setLayoutProperty(layerId, 'visibility', 'none');
|
||||
} catch {
|
||||
// ignore
|
||||
}
|
||||
};
|
||||
|
||||
const ensure = () => {
|
||||
if (projectionBusyRef.current) return;
|
||||
if (!map.isStyleLoaded()) return;
|
||||
if (projection !== 'globe' || !overlays.pairRange) {
|
||||
remove();
|
||||
return;
|
||||
}
|
||||
|
||||
const ranges: PairRangeCircle[] = [];
|
||||
for (const p of pairLinks || []) {
|
||||
const center: [number, number] = [(p.from[0] + p.to[0]) / 2, (p.from[1] + p.to[1]) / 2];
|
||||
ranges.push({
|
||||
center,
|
||||
radiusNm: Math.max(0.05, p.distanceNm / 2),
|
||||
warn: p.warn,
|
||||
aMmsi: p.aMmsi,
|
||||
bMmsi: p.bMmsi,
|
||||
distanceNm: p.distanceNm,
|
||||
});
|
||||
}
|
||||
if (ranges.length === 0) {
|
||||
remove();
|
||||
return;
|
||||
}
|
||||
|
||||
const fc: GeoJSON.FeatureCollection<GeoJSON.LineString> = {
|
||||
type: 'FeatureCollection',
|
||||
features: ranges.map((c) => {
|
||||
const ring = circleRingLngLat(c.center, c.radiusNm * 1852);
|
||||
return {
|
||||
type: 'Feature',
|
||||
id: makePairLinkFeatureId(c.aMmsi, c.bMmsi),
|
||||
geometry: { type: 'LineString', coordinates: ring },
|
||||
properties: {
|
||||
type: 'pair-range',
|
||||
warn: c.warn,
|
||||
aMmsi: c.aMmsi,
|
||||
bMmsi: c.bMmsi,
|
||||
distanceNm: c.distanceNm,
|
||||
highlighted: 0,
|
||||
},
|
||||
};
|
||||
}),
|
||||
};
|
||||
|
||||
try {
|
||||
const existing = map.getSource(srcId) as GeoJSONSource | undefined;
|
||||
if (existing) existing.setData(fc);
|
||||
else map.addSource(srcId, { type: 'geojson', data: fc } as GeoJSONSourceSpecification);
|
||||
} catch (e) {
|
||||
console.warn('Pair range source setup failed:', e);
|
||||
return;
|
||||
}
|
||||
|
||||
if (!map.getLayer(layerId)) {
|
||||
try {
|
||||
map.addLayer(
|
||||
{
|
||||
id: layerId,
|
||||
type: 'line',
|
||||
source: srcId,
|
||||
layout: { 'line-cap': 'round', 'line-join': 'round', visibility: 'visible' },
|
||||
paint: {
|
||||
'line-color': [
|
||||
'case',
|
||||
['==', ['get', 'highlighted'], 1],
|
||||
['case', ['boolean', ['get', 'warn'], false], PAIR_RANGE_WARN_ML_HL, PAIR_RANGE_NORMAL_ML_HL],
|
||||
['boolean', ['get', 'warn'], false],
|
||||
PAIR_RANGE_WARN_ML,
|
||||
PAIR_RANGE_NORMAL_ML,
|
||||
] as never,
|
||||
'line-width': ['case', ['==', ['get', 'highlighted'], 1], 1.6, 1.0] as never,
|
||||
'line-opacity': 0.85,
|
||||
},
|
||||
} as unknown as LayerSpecification,
|
||||
undefined,
|
||||
);
|
||||
} catch (e) {
|
||||
console.warn('Pair range layer add failed:', e);
|
||||
}
|
||||
} else {
|
||||
try {
|
||||
map.setLayoutProperty(layerId, 'visibility', 'visible');
|
||||
} catch {
|
||||
// ignore
|
||||
}
|
||||
}
|
||||
|
||||
kickRepaint(map);
|
||||
};
|
||||
|
||||
const stop = onMapStyleReady(map, ensure);
|
||||
ensure();
|
||||
return () => {
|
||||
stop();
|
||||
};
|
||||
}, [projection, overlays.pairRange, pairLinks, mapSyncEpoch, reorderGlobeFeatureLayers]);
|
||||
|
||||
// Paint state updates for hover highlights
|
||||
// eslint-disable-next-line react-hooks/preserve-manual-memoization
|
||||
const updateGlobeOverlayPaintStates = useCallback(() => {
|
||||
if (projection !== 'globe' || projectionBusyRef.current) return;
|
||||
|
||||
const map = mapRef.current;
|
||||
if (!map || !map.isStyleLoaded()) return;
|
||||
|
||||
const fleetAwarePairMmsiList = makeUniqueSorted([...hoveredPairMmsiList, ...hoveredFleetMmsiList]);
|
||||
|
||||
const pairHighlightExpr = hoveredPairMmsiList.length >= 2
|
||||
? makeMmsiPairHighlightExpr('aMmsi', 'bMmsi', hoveredPairMmsiList)
|
||||
: false;
|
||||
|
||||
const fcEndpointHighlightExpr = fleetAwarePairMmsiList.length > 0
|
||||
? makeMmsiAnyEndpointExpr('fcMmsi', 'otherMmsi', fleetAwarePairMmsiList)
|
||||
: false;
|
||||
|
||||
const fleetOwnerMatchExpr =
|
||||
hoveredFleetOwnerKeyList.length > 0 ? makeFleetOwnerMatchExpr(hoveredFleetOwnerKeyList) : false;
|
||||
const fleetMemberExpr =
|
||||
hoveredFleetMmsiList.length > 0 ? makeFleetMemberMatchExpr(hoveredFleetMmsiList) : false;
|
||||
const fleetHighlightExpr =
|
||||
hoveredFleetOwnerKeyList.length > 0 || hoveredFleetMmsiList.length > 0
|
||||
? (['any', fleetOwnerMatchExpr, fleetMemberExpr] as never)
|
||||
: false;
|
||||
|
||||
try {
|
||||
if (map.getLayer('pair-lines-ml')) {
|
||||
map.setPaintProperty(
|
||||
'pair-lines-ml', 'line-color',
|
||||
['case', pairHighlightExpr, ['case', ['boolean', ['get', 'warn'], false], PAIR_LINE_WARN_ML_HL, PAIR_LINE_NORMAL_ML_HL], ['boolean', ['get', 'warn'], false], PAIR_LINE_WARN_ML, PAIR_LINE_NORMAL_ML] as never,
|
||||
);
|
||||
map.setPaintProperty(
|
||||
'pair-lines-ml', 'line-width',
|
||||
['case', pairHighlightExpr, 2.8, ['boolean', ['get', 'warn'], false], 2.2, 1.4] as never,
|
||||
);
|
||||
}
|
||||
} catch {
|
||||
// ignore
|
||||
}
|
||||
|
||||
try {
|
||||
if (map.getLayer('fc-lines-ml')) {
|
||||
map.setPaintProperty(
|
||||
'fc-lines-ml', 'line-color',
|
||||
['case', fcEndpointHighlightExpr, ['case', ['boolean', ['get', 'suspicious'], false], FC_LINE_SUSPICIOUS_ML_HL, FC_LINE_NORMAL_ML_HL], ['boolean', ['get', 'suspicious'], false], FC_LINE_SUSPICIOUS_ML, FC_LINE_NORMAL_ML] as never,
|
||||
);
|
||||
map.setPaintProperty(
|
||||
'fc-lines-ml', 'line-width',
|
||||
['case', fcEndpointHighlightExpr, 2.0, 1.3] as never,
|
||||
);
|
||||
}
|
||||
} catch {
|
||||
// ignore
|
||||
}
|
||||
|
||||
try {
|
||||
if (map.getLayer('pair-range-ml')) {
|
||||
map.setPaintProperty(
|
||||
'pair-range-ml', 'line-color',
|
||||
['case', pairHighlightExpr, ['case', ['boolean', ['get', 'warn'], false], PAIR_RANGE_WARN_ML_HL, PAIR_RANGE_NORMAL_ML_HL], ['boolean', ['get', 'warn'], false], PAIR_RANGE_WARN_ML, PAIR_RANGE_NORMAL_ML] as never,
|
||||
);
|
||||
map.setPaintProperty(
|
||||
'pair-range-ml', 'line-width',
|
||||
['case', pairHighlightExpr, 1.6, 1.0] as never,
|
||||
);
|
||||
}
|
||||
} catch {
|
||||
// ignore
|
||||
}
|
||||
|
||||
try {
|
||||
if (map.getLayer('fleet-circles-ml-fill')) {
|
||||
map.setPaintProperty('fleet-circles-ml-fill', 'fill-color', ['case', fleetHighlightExpr, FLEET_FILL_ML_HL, FLEET_FILL_ML] as never);
|
||||
map.setPaintProperty('fleet-circles-ml-fill', 'fill-opacity', ['case', fleetHighlightExpr, 0.7, 0.28] as never);
|
||||
}
|
||||
if (map.getLayer('fleet-circles-ml')) {
|
||||
map.setPaintProperty('fleet-circles-ml', 'line-color', ['case', fleetHighlightExpr, FLEET_LINE_ML_HL, FLEET_LINE_ML] as never);
|
||||
map.setPaintProperty('fleet-circles-ml', 'line-width', ['case', fleetHighlightExpr, 2, 1.1] as never);
|
||||
}
|
||||
} catch {
|
||||
// ignore
|
||||
}
|
||||
}, [projection, hoveredFleetMmsiList, hoveredFleetOwnerKeyList, hoveredPairMmsiList]);
|
||||
|
||||
useEffect(() => {
|
||||
const map = mapRef.current;
|
||||
if (!map) return;
|
||||
const stop = onMapStyleReady(map, updateGlobeOverlayPaintStates);
|
||||
updateGlobeOverlayPaintStates();
|
||||
return () => {
|
||||
stop();
|
||||
};
|
||||
}, [mapSyncEpoch, hoveredFleetMmsiList, hoveredFleetOwnerKeyList, hoveredPairMmsiList, projection, updateGlobeOverlayPaintStates]);
|
||||
}
|
||||
1038
apps/web/src/widgets/map3d/hooks/useGlobeShips.ts
Normal file
1038
apps/web/src/widgets/map3d/hooks/useGlobeShips.ts
Normal file
파일 크기가 너무 크기때문에 변경 상태를 표시하지 않습니다.
Load Diff
66
apps/web/src/widgets/map3d/hooks/useHoverState.ts
Normal file
66
apps/web/src/widgets/map3d/hooks/useHoverState.ts
Normal file
@ -0,0 +1,66 @@
|
||||
import { useMemo, useState } from 'react';
|
||||
import { toNumberSet } from '../lib/setUtils';
|
||||
|
||||
export interface HoverStateInput {
|
||||
hoveredMmsiSet: number[];
|
||||
hoveredFleetMmsiSet: number[];
|
||||
hoveredPairMmsiSet: number[];
|
||||
hoveredFleetOwnerKey: string | null;
|
||||
highlightedMmsiSet: number[];
|
||||
}
|
||||
|
||||
export function useHoverState(input: HoverStateInput) {
|
||||
const {
|
||||
hoveredMmsiSet,
|
||||
hoveredFleetMmsiSet,
|
||||
hoveredPairMmsiSet,
|
||||
hoveredFleetOwnerKey,
|
||||
highlightedMmsiSet,
|
||||
} = input;
|
||||
|
||||
// Internal deck hover states
|
||||
const [hoveredDeckMmsiSet, setHoveredDeckMmsiSet] = useState<number[]>([]);
|
||||
const [hoveredDeckPairMmsiSet, setHoveredDeckPairMmsiSet] = useState<number[]>([]);
|
||||
const [hoveredDeckFleetOwnerKey, setHoveredDeckFleetOwnerKey] = useState<string | null>(null);
|
||||
const [hoveredDeckFleetMmsiSet, setHoveredDeckFleetMmsiSet] = useState<number[]>([]);
|
||||
const [hoveredZoneId, setHoveredZoneId] = useState<string | null>(null);
|
||||
|
||||
// Derived sets
|
||||
const hoveredMmsiSetRef = useMemo(() => toNumberSet(hoveredMmsiSet), [hoveredMmsiSet]);
|
||||
const hoveredFleetMmsiSetRef = useMemo(() => toNumberSet(hoveredFleetMmsiSet), [hoveredFleetMmsiSet]);
|
||||
const hoveredPairMmsiSetRef = useMemo(() => toNumberSet(hoveredPairMmsiSet), [hoveredPairMmsiSet]);
|
||||
const externalHighlightedSetRef = useMemo(() => toNumberSet(highlightedMmsiSet), [highlightedMmsiSet]);
|
||||
const hoveredDeckMmsiSetRef = useMemo(() => toNumberSet(hoveredDeckMmsiSet), [hoveredDeckMmsiSet]);
|
||||
const hoveredDeckPairMmsiSetRef = useMemo(() => toNumberSet(hoveredDeckPairMmsiSet), [hoveredDeckPairMmsiSet]);
|
||||
const hoveredDeckFleetMmsiSetRef = useMemo(() => toNumberSet(hoveredDeckFleetMmsiSet), [hoveredDeckFleetMmsiSet]);
|
||||
|
||||
const hoveredFleetOwnerKeys = useMemo(() => {
|
||||
const keys = new Set<string>();
|
||||
if (hoveredFleetOwnerKey) keys.add(hoveredFleetOwnerKey);
|
||||
if (hoveredDeckFleetOwnerKey) keys.add(hoveredDeckFleetOwnerKey);
|
||||
return keys;
|
||||
}, [hoveredFleetOwnerKey, hoveredDeckFleetOwnerKey]);
|
||||
|
||||
return {
|
||||
// Internal states + setters
|
||||
hoveredDeckMmsiSet,
|
||||
setHoveredDeckMmsiSet,
|
||||
hoveredDeckPairMmsiSet,
|
||||
setHoveredDeckPairMmsiSet,
|
||||
hoveredDeckFleetOwnerKey,
|
||||
setHoveredDeckFleetOwnerKey,
|
||||
hoveredDeckFleetMmsiSet,
|
||||
setHoveredDeckFleetMmsiSet,
|
||||
hoveredZoneId,
|
||||
setHoveredZoneId,
|
||||
// Derived sets
|
||||
hoveredMmsiSetRef,
|
||||
hoveredFleetMmsiSetRef,
|
||||
hoveredPairMmsiSetRef,
|
||||
externalHighlightedSetRef,
|
||||
hoveredDeckMmsiSetRef,
|
||||
hoveredDeckPairMmsiSetRef,
|
||||
hoveredDeckFleetMmsiSetRef,
|
||||
hoveredFleetOwnerKeys,
|
||||
};
|
||||
}
|
||||
196
apps/web/src/widgets/map3d/hooks/useMapInit.ts
Normal file
196
apps/web/src/widgets/map3d/hooks/useMapInit.ts
Normal file
@ -0,0 +1,196 @@
|
||||
import { useCallback, useEffect, useRef, type MutableRefObject, type Dispatch, type SetStateAction } from 'react';
|
||||
import maplibregl, { type StyleSpecification } from 'maplibre-gl';
|
||||
import { MapboxOverlay } from '@deck.gl/mapbox';
|
||||
import { MaplibreDeckCustomLayer } from '../MaplibreDeckCustomLayer';
|
||||
import type { BaseMapId, MapProjectionId } from '../types';
|
||||
import { DECK_VIEW_ID } from '../constants';
|
||||
import { kickRepaint, onMapStyleReady } from '../lib/mapCore';
|
||||
import { ensureSeamarkOverlay } from '../layers/seamark';
|
||||
import { resolveMapStyle } from '../layers/bathymetry';
|
||||
import { clearGlobeNativeLayers } from '../lib/layerHelpers';
|
||||
|
||||
export function useMapInit(
|
||||
containerRef: MutableRefObject<HTMLDivElement | null>,
|
||||
mapRef: MutableRefObject<maplibregl.Map | null>,
|
||||
overlayRef: MutableRefObject<MapboxOverlay | null>,
|
||||
overlayInteractionRef: MutableRefObject<MapboxOverlay | null>,
|
||||
globeDeckLayerRef: MutableRefObject<MaplibreDeckCustomLayer | null>,
|
||||
baseMapRef: MutableRefObject<BaseMapId>,
|
||||
projectionRef: MutableRefObject<MapProjectionId>,
|
||||
opts: {
|
||||
baseMap: BaseMapId;
|
||||
projection: MapProjectionId;
|
||||
showSeamark: boolean;
|
||||
onViewBboxChange?: (bbox: [number, number, number, number]) => void;
|
||||
setMapSyncEpoch: Dispatch<SetStateAction<number>>;
|
||||
},
|
||||
) {
|
||||
const { onViewBboxChange, setMapSyncEpoch, showSeamark } = opts;
|
||||
const showSeamarkRef = useRef(showSeamark);
|
||||
useEffect(() => {
|
||||
showSeamarkRef.current = showSeamark;
|
||||
}, [showSeamark]);
|
||||
|
||||
const ensureMercatorOverlay = useCallback(() => {
|
||||
const map = mapRef.current;
|
||||
if (!map) return null;
|
||||
if (overlayRef.current) return overlayRef.current;
|
||||
try {
|
||||
const next = new MapboxOverlay({ interleaved: true, layers: [] } as unknown as never);
|
||||
map.addControl(next);
|
||||
overlayRef.current = next;
|
||||
return next;
|
||||
} catch (e) {
|
||||
console.warn('Deck overlay create failed:', e);
|
||||
return null;
|
||||
}
|
||||
}, []);
|
||||
|
||||
const clearGlobeNativeLayersCb = useCallback(() => {
|
||||
const map = mapRef.current;
|
||||
if (!map) return;
|
||||
clearGlobeNativeLayers(map);
|
||||
}, []);
|
||||
|
||||
const pulseMapSync = useCallback(() => {
|
||||
setMapSyncEpoch((prev) => prev + 1);
|
||||
requestAnimationFrame(() => {
|
||||
kickRepaint(mapRef.current);
|
||||
setMapSyncEpoch((prev) => prev + 1);
|
||||
});
|
||||
}, [setMapSyncEpoch]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!containerRef.current || mapRef.current) return;
|
||||
|
||||
let map: maplibregl.Map | null = null;
|
||||
let cancelled = false;
|
||||
const controller = new AbortController();
|
||||
|
||||
(async () => {
|
||||
let style: string | StyleSpecification = '/map/styles/osm-seamark.json';
|
||||
try {
|
||||
style = await resolveMapStyle(baseMapRef.current, controller.signal);
|
||||
} catch (e) {
|
||||
console.warn('Map style init failed, falling back to local raster style:', e);
|
||||
style = '/map/styles/osm-seamark.json';
|
||||
}
|
||||
if (cancelled || !containerRef.current) return;
|
||||
|
||||
map = new maplibregl.Map({
|
||||
container: containerRef.current,
|
||||
style,
|
||||
center: [126.5, 34.2],
|
||||
zoom: 7,
|
||||
pitch: 45,
|
||||
bearing: 0,
|
||||
maxPitch: 85,
|
||||
dragRotate: true,
|
||||
pitchWithRotate: true,
|
||||
touchPitch: true,
|
||||
scrollZoom: { around: 'center' },
|
||||
});
|
||||
|
||||
map.addControl(new maplibregl.NavigationControl({ showZoom: true, showCompass: true }), 'top-left');
|
||||
map.addControl(new maplibregl.ScaleControl({ maxWidth: 120, unit: 'metric' }), 'bottom-left');
|
||||
|
||||
mapRef.current = map;
|
||||
|
||||
if (projectionRef.current === 'mercator') {
|
||||
const overlay = ensureMercatorOverlay();
|
||||
if (!overlay) return;
|
||||
overlayRef.current = overlay;
|
||||
} else {
|
||||
globeDeckLayerRef.current = new MaplibreDeckCustomLayer({
|
||||
id: 'deck-globe',
|
||||
viewId: DECK_VIEW_ID,
|
||||
deckProps: { layers: [] },
|
||||
});
|
||||
}
|
||||
|
||||
function applyProjection() {
|
||||
if (!map) return;
|
||||
const next = projectionRef.current;
|
||||
if (next === 'mercator') return;
|
||||
try {
|
||||
map.setProjection({ type: next });
|
||||
map.setRenderWorldCopies(next !== 'globe');
|
||||
} catch (e) {
|
||||
console.warn('Projection apply failed:', e);
|
||||
}
|
||||
}
|
||||
|
||||
onMapStyleReady(map, () => {
|
||||
applyProjection();
|
||||
const deckLayer = globeDeckLayerRef.current;
|
||||
if (projectionRef.current === 'globe' && deckLayer && !map!.getLayer(deckLayer.id)) {
|
||||
try {
|
||||
map!.addLayer(deckLayer);
|
||||
} catch {
|
||||
// ignore
|
||||
}
|
||||
}
|
||||
if (!showSeamarkRef.current) return;
|
||||
try {
|
||||
ensureSeamarkOverlay(map!, 'bathymetry-lines');
|
||||
} catch {
|
||||
// ignore
|
||||
}
|
||||
});
|
||||
|
||||
const emitBbox = () => {
|
||||
const cb = onViewBboxChange;
|
||||
if (!cb || !map) return;
|
||||
const b = map.getBounds();
|
||||
cb([b.getWest(), b.getSouth(), b.getEast(), b.getNorth()]);
|
||||
};
|
||||
map.on('load', emitBbox);
|
||||
map.on('moveend', emitBbox);
|
||||
|
||||
map.once('load', () => {
|
||||
if (showSeamarkRef.current) {
|
||||
try {
|
||||
ensureSeamarkOverlay(map!, 'bathymetry-lines');
|
||||
} catch {
|
||||
// ignore
|
||||
}
|
||||
try {
|
||||
const opacity = showSeamarkRef.current ? 0.85 : 0;
|
||||
map!.setPaintProperty('seamark', 'raster-opacity', opacity);
|
||||
} catch {
|
||||
// ignore
|
||||
}
|
||||
}
|
||||
});
|
||||
})();
|
||||
|
||||
return () => {
|
||||
cancelled = true;
|
||||
controller.abort();
|
||||
|
||||
try {
|
||||
globeDeckLayerRef.current?.requestFinalize();
|
||||
} catch {
|
||||
// ignore
|
||||
}
|
||||
|
||||
if (map) {
|
||||
map.remove();
|
||||
map = null;
|
||||
}
|
||||
if (overlayRef.current) {
|
||||
overlayRef.current.finalize();
|
||||
overlayRef.current = null;
|
||||
}
|
||||
if (overlayInteractionRef.current) {
|
||||
overlayInteractionRef.current.finalize();
|
||||
overlayInteractionRef.current = null;
|
||||
}
|
||||
globeDeckLayerRef.current = null;
|
||||
mapRef.current = null;
|
||||
};
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, []);
|
||||
|
||||
return { ensureMercatorOverlay, clearGlobeNativeLayers: clearGlobeNativeLayersCb, pulseMapSync };
|
||||
}
|
||||
179
apps/web/src/widgets/map3d/hooks/useMapStyleSettings.ts
Normal file
179
apps/web/src/widgets/map3d/hooks/useMapStyleSettings.ts
Normal file
@ -0,0 +1,179 @@
|
||||
import { useEffect, useRef, type MutableRefObject } from 'react';
|
||||
import maplibregl from 'maplibre-gl';
|
||||
import type { MapStyleSettings, MapLabelLanguage, DepthColorStop, DepthFontSize } from '../../../features/mapSettings/types';
|
||||
import type { BaseMapId } from '../types';
|
||||
import { kickRepaint, onMapStyleReady } from '../lib/mapCore';
|
||||
|
||||
/* ── Depth font size presets ──────────────────────────────────────── */
|
||||
const DEPTH_FONT_SIZE_MAP: Record<DepthFontSize, unknown[]> = {
|
||||
small: ['interpolate', ['linear'], ['zoom'], 7, 8, 9, 9, 11, 11, 13, 13],
|
||||
medium: ['interpolate', ['linear'], ['zoom'], 7, 10, 9, 12, 11, 14, 13, 16],
|
||||
large: ['interpolate', ['linear'], ['zoom'], 7, 12, 9, 15, 11, 18, 13, 20],
|
||||
};
|
||||
|
||||
/* ── Helpers ──────────────────────────────────────────────────────── */
|
||||
function darkenHex(hex: string, factor = 0.85): string {
|
||||
const r = parseInt(hex.slice(1, 3), 16);
|
||||
const g = parseInt(hex.slice(3, 5), 16);
|
||||
const b = parseInt(hex.slice(5, 7), 16);
|
||||
return `#${[r, g, b].map((c) => Math.round(c * factor).toString(16).padStart(2, '0')).join('')}`;
|
||||
}
|
||||
|
||||
function lightenHex(hex: string, factor = 1.3): string {
|
||||
const r = parseInt(hex.slice(1, 3), 16);
|
||||
const g = parseInt(hex.slice(3, 5), 16);
|
||||
const b = parseInt(hex.slice(5, 7), 16);
|
||||
return `#${[r, g, b].map((c) => Math.min(255, Math.round(c * factor)).toString(16).padStart(2, '0')).join('')}`;
|
||||
}
|
||||
|
||||
/* ── Apply functions ──────────────────────────────────────────────── */
|
||||
|
||||
function applyLabelLanguage(map: maplibregl.Map, lang: MapLabelLanguage) {
|
||||
const style = map.getStyle();
|
||||
if (!style?.layers) return;
|
||||
for (const layer of style.layers) {
|
||||
if (layer.type !== 'symbol') continue;
|
||||
const layout = (layer as { layout?: Record<string, unknown> }).layout;
|
||||
if (!layout?.['text-field']) continue;
|
||||
if (layer.id === 'bathymetry-labels') continue;
|
||||
const textField =
|
||||
lang === 'local'
|
||||
? ['get', 'name']
|
||||
: ['coalesce', ['get', `name:${lang}`], ['get', 'name']];
|
||||
try {
|
||||
map.setLayoutProperty(layer.id, 'text-field', textField);
|
||||
} catch {
|
||||
// ignore
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
function applyLandColor(map: maplibregl.Map, color: string) {
|
||||
const style = map.getStyle();
|
||||
if (!style?.layers) return;
|
||||
const waterRegex = /(water|sea|ocean|river|lake|coast|bay)/i;
|
||||
const darkVariant = darkenHex(color, 0.8);
|
||||
for (const layer of style.layers) {
|
||||
const id = layer.id;
|
||||
if (id.startsWith('bathymetry-')) continue;
|
||||
if (id.startsWith('subcables-')) continue;
|
||||
if (id.startsWith('zones-')) continue;
|
||||
if (id.startsWith('ships-')) continue;
|
||||
if (id.startsWith('pair-')) continue;
|
||||
if (id.startsWith('fc-')) continue;
|
||||
if (id.startsWith('fleet-')) continue;
|
||||
if (id.startsWith('predict-')) continue;
|
||||
if (id === 'deck-globe') continue;
|
||||
const sourceLayer = String((layer as Record<string, unknown>)['source-layer'] ?? '');
|
||||
const isWater = waterRegex.test(id) || waterRegex.test(sourceLayer);
|
||||
if (isWater) continue;
|
||||
try {
|
||||
if (layer.type === 'background') {
|
||||
map.setPaintProperty(id, 'background-color', color);
|
||||
} else if (layer.type === 'fill') {
|
||||
map.setPaintProperty(id, 'fill-color', darkVariant);
|
||||
}
|
||||
} catch {
|
||||
// ignore
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
function applyWaterBaseColor(map: maplibregl.Map, fillColor: string) {
|
||||
const style = map.getStyle();
|
||||
if (!style?.layers) return;
|
||||
const waterRegex = /(water|sea|ocean|river|lake|coast|bay)/i;
|
||||
const lineColor = darkenHex(fillColor, 0.85);
|
||||
for (const layer of style.layers) {
|
||||
const id = layer.id;
|
||||
if (id.startsWith('bathymetry-')) continue;
|
||||
const sourceLayer = String((layer as Record<string, unknown>)['source-layer'] ?? '');
|
||||
if (!waterRegex.test(id) && !waterRegex.test(sourceLayer)) continue;
|
||||
try {
|
||||
if (layer.type === 'fill') {
|
||||
map.setPaintProperty(id, 'fill-color', fillColor);
|
||||
} else if (layer.type === 'line') {
|
||||
map.setPaintProperty(id, 'line-color', lineColor);
|
||||
}
|
||||
} catch {
|
||||
// ignore
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
function applyDepthGradient(map: maplibregl.Map, stops: DepthColorStop[]) {
|
||||
if (!map.getLayer('bathymetry-fill')) return;
|
||||
const depth = ['to-number', ['get', 'depth']];
|
||||
const sorted = [...stops].sort((a, b) => a.depth - b.depth);
|
||||
const expr: unknown[] = ['interpolate', ['linear'], depth];
|
||||
const deepest = sorted[0];
|
||||
if (deepest) expr.push(-11000, darkenHex(deepest.color, 0.5));
|
||||
for (const s of sorted) {
|
||||
expr.push(s.depth, s.color);
|
||||
}
|
||||
const shallowest = sorted[sorted.length - 1];
|
||||
if (shallowest) expr.push(0, lightenHex(shallowest.color, 1.8));
|
||||
try {
|
||||
map.setPaintProperty('bathymetry-fill', 'fill-color', expr as never);
|
||||
} catch {
|
||||
// ignore
|
||||
}
|
||||
}
|
||||
|
||||
function applyDepthFontSize(map: maplibregl.Map, size: DepthFontSize) {
|
||||
const expr = DEPTH_FONT_SIZE_MAP[size];
|
||||
for (const layerId of ['bathymetry-labels', 'bathymetry-landforms']) {
|
||||
if (!map.getLayer(layerId)) continue;
|
||||
try {
|
||||
map.setLayoutProperty(layerId, 'text-size', expr);
|
||||
} catch {
|
||||
// ignore
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
function applyDepthFontColor(map: maplibregl.Map, color: string) {
|
||||
for (const layerId of ['bathymetry-labels', 'bathymetry-landforms']) {
|
||||
if (!map.getLayer(layerId)) continue;
|
||||
try {
|
||||
map.setPaintProperty(layerId, 'text-color', color);
|
||||
} catch {
|
||||
// ignore
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/* ── Hook ──────────────────────────────────────────────────────────── */
|
||||
export function useMapStyleSettings(
|
||||
mapRef: MutableRefObject<maplibregl.Map | null>,
|
||||
settings: MapStyleSettings | undefined,
|
||||
opts: { baseMap: BaseMapId; mapSyncEpoch: number },
|
||||
) {
|
||||
const settingsRef = useRef(settings);
|
||||
useEffect(() => {
|
||||
settingsRef.current = settings;
|
||||
});
|
||||
|
||||
const { baseMap, mapSyncEpoch } = opts;
|
||||
|
||||
useEffect(() => {
|
||||
const map = mapRef.current;
|
||||
const s = settingsRef.current;
|
||||
if (!map || !s) return;
|
||||
|
||||
const stop = onMapStyleReady(map, () => {
|
||||
applyLabelLanguage(map, s.labelLanguage);
|
||||
applyLandColor(map, s.landColor);
|
||||
applyWaterBaseColor(map, s.waterBaseColor);
|
||||
if (baseMap === 'enhanced') {
|
||||
applyDepthGradient(map, s.depthStops);
|
||||
applyDepthFontSize(map, s.depthFontSize);
|
||||
applyDepthFontColor(map, s.depthFontColor);
|
||||
}
|
||||
kickRepaint(map);
|
||||
});
|
||||
|
||||
return () => stop();
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, [settings, baseMap, mapSyncEpoch]);
|
||||
}
|
||||
169
apps/web/src/widgets/map3d/hooks/useNativeMapLayers.ts
Normal file
169
apps/web/src/widgets/map3d/hooks/useNativeMapLayers.ts
Normal file
@ -0,0 +1,169 @@
|
||||
/**
|
||||
* useNativeMapLayers — Mercator/Globe 공통 MapLibre 네이티브 레이어 관리 hook
|
||||
*
|
||||
* 반복되는 보일러플레이트를 자동화합니다:
|
||||
* - projectionBusy / isStyleLoaded 가드
|
||||
* - GeoJSON source 생성/업데이트
|
||||
* - Layer 생성 (ensureLayer)
|
||||
* - Visibility 토글
|
||||
* - Globe 레이어 순서 관리 (reorderGlobeFeatureLayers)
|
||||
* - kickRepaint
|
||||
* - Unmount 시 cleanupLayers
|
||||
*
|
||||
* 호버 하이라이트, 마우스 이벤트 등 레이어별 커스텀 로직은
|
||||
* 별도 useEffect에서 처리합니다.
|
||||
*/
|
||||
import { useEffect, useRef, type MutableRefObject } from 'react';
|
||||
import maplibregl, { type GeoJSONSourceSpecification, type LayerSpecification } from 'maplibre-gl';
|
||||
import { ensureGeoJsonSource, ensureLayer, setLayerVisibility, cleanupLayers } from '../lib/layerHelpers';
|
||||
import { kickRepaint, onMapStyleReady } from '../lib/mapCore';
|
||||
|
||||
/* ── Public types ──────────────────────────────────────────────────── */
|
||||
|
||||
export interface NativeSourceConfig {
|
||||
id: string;
|
||||
data: GeoJSON.GeoJSON | null;
|
||||
/** GeoJSON source 옵션 (tolerance, buffer 등) */
|
||||
options?: Partial<Omit<GeoJSONSourceSpecification, 'type' | 'data'>>;
|
||||
}
|
||||
|
||||
export interface NativeLayerSpec {
|
||||
id: string;
|
||||
type: 'line' | 'fill' | 'circle' | 'symbol';
|
||||
sourceId: string;
|
||||
paint: Record<string, unknown>;
|
||||
layout?: Record<string, unknown>;
|
||||
filter?: unknown[];
|
||||
minzoom?: number;
|
||||
maxzoom?: number;
|
||||
}
|
||||
|
||||
export interface NativeMapLayersConfig {
|
||||
/** GeoJSON 데이터 소스 (다중 지원) */
|
||||
sources: NativeSourceConfig[];
|
||||
/** 레이어 스펙 배열 (생성 순서대로) */
|
||||
layers: NativeLayerSpec[];
|
||||
/** 전체 레이어 on/off */
|
||||
visible: boolean;
|
||||
/**
|
||||
* 이 레이어들을 삽입할 기준 레이어 ID.
|
||||
* 배열이면 첫 번째로 존재하는 레이어를 사용합니다.
|
||||
*/
|
||||
beforeLayer?: string | string[];
|
||||
/**
|
||||
* 레이어 (재)생성 후 호출되는 콜백.
|
||||
* 호버 하이라이트 재적용 등에 사용합니다.
|
||||
*/
|
||||
onAfterSetup?: (map: maplibregl.Map) => void;
|
||||
}
|
||||
|
||||
/* ── Hook ──────────────────────────────────────────────────────────── */
|
||||
|
||||
/**
|
||||
* @param mapRef - Map 인스턴스 ref
|
||||
* @param projectionBusyRef - 프로젝션 전환 중 가드 ref
|
||||
* @param reorderGlobeFeatureLayers - Globe 레이어 순서 재정렬 함수
|
||||
* @param config - 소스/레이어/visibility 설정
|
||||
* @param deps - 이 값이 변경되면 레이어를 다시 셋업합니다.
|
||||
* (subcableGeo, overlays.subcables, projection, mapSyncEpoch 등)
|
||||
*/
|
||||
export function useNativeMapLayers(
|
||||
mapRef: MutableRefObject<maplibregl.Map | null>,
|
||||
projectionBusyRef: MutableRefObject<boolean>,
|
||||
reorderGlobeFeatureLayers: () => void,
|
||||
config: NativeMapLayersConfig,
|
||||
deps: readonly unknown[],
|
||||
) {
|
||||
// 최신 config를 항상 읽기 위한 ref (deps에 config 객체를 넣지 않기 위함)
|
||||
const configRef = useRef(config);
|
||||
useEffect(() => {
|
||||
configRef.current = config;
|
||||
});
|
||||
|
||||
/* ── 레이어 생성/데이터 업데이트 ─────────────────────────────────── */
|
||||
useEffect(() => {
|
||||
const map = mapRef.current;
|
||||
if (!map) return;
|
||||
|
||||
const ensure = () => {
|
||||
const cfg = configRef.current;
|
||||
if (projectionBusyRef.current) return;
|
||||
|
||||
// 1. Visibility 토글
|
||||
for (const spec of cfg.layers) {
|
||||
setLayerVisibility(map, spec.id, cfg.visible);
|
||||
}
|
||||
|
||||
// 2. 데이터가 있는 source가 하나도 없으면 종료
|
||||
const hasData = cfg.sources.some((s) => s.data != null);
|
||||
if (!hasData) return;
|
||||
if (!map.isStyleLoaded()) return;
|
||||
|
||||
try {
|
||||
// 3. Source 생성/업데이트
|
||||
for (const src of cfg.sources) {
|
||||
if (src.data) {
|
||||
ensureGeoJsonSource(map, src.id, src.data, src.options);
|
||||
}
|
||||
}
|
||||
|
||||
// 4. Before layer 해석
|
||||
let before: string | undefined;
|
||||
if (cfg.beforeLayer) {
|
||||
const candidates = Array.isArray(cfg.beforeLayer) ? cfg.beforeLayer : [cfg.beforeLayer];
|
||||
for (const candidate of candidates) {
|
||||
if (map.getLayer(candidate)) {
|
||||
before = candidate;
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// 5. Layer 생성
|
||||
const vis = cfg.visible ? 'visible' : 'none';
|
||||
for (const spec of cfg.layers) {
|
||||
const layerDef: Record<string, unknown> = {
|
||||
id: spec.id,
|
||||
type: spec.type,
|
||||
source: spec.sourceId,
|
||||
paint: spec.paint,
|
||||
layout: { ...spec.layout, visibility: vis },
|
||||
};
|
||||
if (spec.filter) layerDef.filter = spec.filter;
|
||||
if (spec.minzoom != null) layerDef.minzoom = spec.minzoom;
|
||||
if (spec.maxzoom != null) layerDef.maxzoom = spec.maxzoom;
|
||||
|
||||
ensureLayer(map, layerDef as unknown as LayerSpecification, { before });
|
||||
}
|
||||
|
||||
// 6. Post-setup callback
|
||||
if (cfg.onAfterSetup) {
|
||||
cfg.onAfterSetup(map);
|
||||
}
|
||||
} catch (e) {
|
||||
console.warn('Native map layers setup failed:', e);
|
||||
} finally {
|
||||
reorderGlobeFeatureLayers();
|
||||
kickRepaint(map);
|
||||
}
|
||||
};
|
||||
|
||||
const stop = onMapStyleReady(map, ensure);
|
||||
return () => {
|
||||
stop();
|
||||
};
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, deps);
|
||||
|
||||
/* ── Unmount cleanup ─────────────────────────────────────────────── */
|
||||
useEffect(() => {
|
||||
const mapInstance = mapRef.current;
|
||||
return () => {
|
||||
if (!mapInstance) return;
|
||||
const cfg = configRef.current;
|
||||
const layerIds = [...cfg.layers].reverse().map((l) => l.id);
|
||||
const sourceIds = [...cfg.sources].reverse().map((s) => s.id);
|
||||
cleanupLayers(mapInstance, layerIds, sourceIds);
|
||||
};
|
||||
}, []);
|
||||
}
|
||||
210
apps/web/src/widgets/map3d/hooks/usePredictionVectors.ts
Normal file
210
apps/web/src/widgets/map3d/hooks/usePredictionVectors.ts
Normal file
@ -0,0 +1,210 @@
|
||||
import { useEffect, type MutableRefObject } from 'react';
|
||||
import type maplibregl from 'maplibre-gl';
|
||||
import type { GeoJSONSource, GeoJSONSourceSpecification, LayerSpecification } from 'maplibre-gl';
|
||||
import type { AisTarget } from '../../../entities/aisTarget/model/types';
|
||||
import type { LegacyVesselInfo } from '../../../entities/legacyVessel/model/types';
|
||||
import type { MapToggleState } from '../../../features/mapToggles/MapToggles';
|
||||
import type { Map3DSettings, MapProjectionId } from '../types';
|
||||
import { LEGACY_CODE_COLORS_RGB, OTHER_AIS_SPEED_RGB, rgba as rgbaCss } from '../../../shared/lib/map/palette';
|
||||
import { kickRepaint, onMapStyleReady } from '../lib/mapCore';
|
||||
import { destinationPointLngLat } from '../lib/geometry';
|
||||
import { isFiniteNumber } from '../lib/setUtils';
|
||||
import { toValidBearingDeg, lightenColor } from '../lib/shipUtils';
|
||||
|
||||
export function usePredictionVectors(
|
||||
mapRef: MutableRefObject<maplibregl.Map | null>,
|
||||
projectionBusyRef: MutableRefObject<boolean>,
|
||||
reorderGlobeFeatureLayers: () => void,
|
||||
opts: {
|
||||
overlays: MapToggleState;
|
||||
settings: Map3DSettings;
|
||||
shipData: AisTarget[];
|
||||
legacyHits: Map<number, LegacyVesselInfo> | null | undefined;
|
||||
selectedMmsi: number | null;
|
||||
externalHighlightedSetRef: Set<number>;
|
||||
projection: MapProjectionId;
|
||||
baseMap: string;
|
||||
mapSyncEpoch: number;
|
||||
},
|
||||
) {
|
||||
const { overlays, settings, shipData, legacyHits, selectedMmsi, externalHighlightedSetRef, projection, baseMap, mapSyncEpoch } = opts;
|
||||
|
||||
useEffect(() => {
|
||||
const map = mapRef.current;
|
||||
if (!map) return;
|
||||
|
||||
const srcId = 'predict-vectors-src';
|
||||
const outlineId = 'predict-vectors-outline';
|
||||
const lineId = 'predict-vectors';
|
||||
const hlOutlineId = 'predict-vectors-hl-outline';
|
||||
const hlId = 'predict-vectors-hl';
|
||||
|
||||
const ensure = () => {
|
||||
if (projectionBusyRef.current) return;
|
||||
if (!map.isStyleLoaded()) return;
|
||||
|
||||
const visibility = overlays.predictVectors ? 'visible' : 'none';
|
||||
|
||||
const horizonMinutes = 15;
|
||||
const horizonSeconds = horizonMinutes * 60;
|
||||
const metersPerSecondPerKnot = 0.514444;
|
||||
|
||||
const features: GeoJSON.Feature<GeoJSON.LineString>[] = [];
|
||||
if (overlays.predictVectors && settings.showShips && shipData.length > 0) {
|
||||
for (const t of shipData) {
|
||||
const legacy = legacyHits?.get(t.mmsi) ?? null;
|
||||
const isTarget = !!legacy;
|
||||
const isSelected = selectedMmsi != null && t.mmsi === selectedMmsi;
|
||||
const isPinnedHighlight = externalHighlightedSetRef.has(t.mmsi);
|
||||
if (!isTarget && !isSelected && !isPinnedHighlight) continue;
|
||||
|
||||
const sog = isFiniteNumber(t.sog) ? t.sog : null;
|
||||
const bearing = toValidBearingDeg(t.cog) ?? toValidBearingDeg(t.heading);
|
||||
if (sog == null || bearing == null) continue;
|
||||
if (sog < 0.2) continue;
|
||||
|
||||
const distM = sog * metersPerSecondPerKnot * horizonSeconds;
|
||||
if (!Number.isFinite(distM) || distM <= 0) continue;
|
||||
|
||||
const to = destinationPointLngLat([t.lon, t.lat], bearing, distM);
|
||||
|
||||
const baseRgb = isTarget
|
||||
? LEGACY_CODE_COLORS_RGB[legacy?.shipCode ?? ''] ?? OTHER_AIS_SPEED_RGB.moving
|
||||
: OTHER_AIS_SPEED_RGB.moving;
|
||||
const rgb = lightenColor(baseRgb, isTarget ? 0.55 : 0.62);
|
||||
const alpha = isTarget ? 0.72 : 0.52;
|
||||
const alphaHl = isTarget ? 0.92 : 0.84;
|
||||
const hl = isSelected || isPinnedHighlight ? 1 : 0;
|
||||
|
||||
features.push({
|
||||
type: 'Feature',
|
||||
id: `pred-${t.mmsi}`,
|
||||
geometry: { type: 'LineString', coordinates: [[t.lon, t.lat], to] },
|
||||
properties: {
|
||||
mmsi: t.mmsi,
|
||||
minutes: horizonMinutes,
|
||||
sog,
|
||||
cog: bearing,
|
||||
target: isTarget ? 1 : 0,
|
||||
hl,
|
||||
color: rgbaCss(rgb, alpha),
|
||||
colorHl: rgbaCss(rgb, alphaHl),
|
||||
},
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
const fc: GeoJSON.FeatureCollection<GeoJSON.LineString> = { type: 'FeatureCollection', features };
|
||||
|
||||
try {
|
||||
const existing = map.getSource(srcId) as GeoJSONSource | undefined;
|
||||
if (existing) existing.setData(fc);
|
||||
else map.addSource(srcId, { type: 'geojson', data: fc } as GeoJSONSourceSpecification);
|
||||
} catch (e) {
|
||||
console.warn('Prediction vector source setup failed:', e);
|
||||
return;
|
||||
}
|
||||
|
||||
const ensureLayer = (id: string, paint: LayerSpecification['paint'], filter: unknown[]) => {
|
||||
if (!map.getLayer(id)) {
|
||||
try {
|
||||
map.addLayer(
|
||||
{
|
||||
id,
|
||||
type: 'line',
|
||||
source: srcId,
|
||||
filter: filter as never,
|
||||
layout: {
|
||||
visibility,
|
||||
'line-cap': 'round',
|
||||
'line-join': 'round',
|
||||
},
|
||||
paint,
|
||||
} as unknown as LayerSpecification,
|
||||
undefined,
|
||||
);
|
||||
} catch (e) {
|
||||
console.warn('Prediction vector layer add failed:', e);
|
||||
}
|
||||
} else {
|
||||
try {
|
||||
map.setLayoutProperty(id, 'visibility', visibility);
|
||||
map.setFilter(id, filter as never);
|
||||
if (paint && typeof paint === 'object') {
|
||||
for (const [key, value] of Object.entries(paint)) {
|
||||
map.setPaintProperty(id, key as never, value as never);
|
||||
}
|
||||
}
|
||||
} catch {
|
||||
// ignore
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
const baseFilter = ['==', ['to-number', ['get', 'hl'], 0], 0] as unknown as unknown[];
|
||||
const hlFilter = ['==', ['to-number', ['get', 'hl'], 0], 1] as unknown as unknown[];
|
||||
|
||||
ensureLayer(
|
||||
outlineId,
|
||||
{
|
||||
'line-color': 'rgba(2,6,23,0.86)',
|
||||
'line-width': 4.8,
|
||||
'line-opacity': 1,
|
||||
'line-blur': 0.2,
|
||||
'line-dasharray': [1.2, 1.8] as never,
|
||||
} as never,
|
||||
baseFilter,
|
||||
);
|
||||
ensureLayer(
|
||||
lineId,
|
||||
{
|
||||
'line-color': ['coalesce', ['get', 'color'], 'rgba(226,232,240,0.62)'] as never,
|
||||
'line-width': 2.4,
|
||||
'line-opacity': 1,
|
||||
'line-dasharray': [1.2, 1.8] as never,
|
||||
} as never,
|
||||
baseFilter,
|
||||
);
|
||||
ensureLayer(
|
||||
hlOutlineId,
|
||||
{
|
||||
'line-color': 'rgba(2,6,23,0.92)',
|
||||
'line-width': 6.4,
|
||||
'line-opacity': 1,
|
||||
'line-blur': 0.25,
|
||||
'line-dasharray': [1.2, 1.8] as never,
|
||||
} as never,
|
||||
hlFilter,
|
||||
);
|
||||
ensureLayer(
|
||||
hlId,
|
||||
{
|
||||
'line-color': ['coalesce', ['get', 'colorHl'], ['get', 'color'], 'rgba(241,245,249,0.92)'] as never,
|
||||
'line-width': 3.6,
|
||||
'line-opacity': 1,
|
||||
'line-dasharray': [1.2, 1.8] as never,
|
||||
} as never,
|
||||
hlFilter,
|
||||
);
|
||||
|
||||
reorderGlobeFeatureLayers();
|
||||
kickRepaint(map);
|
||||
};
|
||||
|
||||
const stop = onMapStyleReady(map, ensure);
|
||||
return () => {
|
||||
stop();
|
||||
};
|
||||
}, [
|
||||
overlays.predictVectors,
|
||||
settings.showShips,
|
||||
shipData,
|
||||
legacyHits,
|
||||
selectedMmsi,
|
||||
externalHighlightedSetRef,
|
||||
projection,
|
||||
baseMap,
|
||||
mapSyncEpoch,
|
||||
reorderGlobeFeatureLayers,
|
||||
]);
|
||||
}
|
||||
330
apps/web/src/widgets/map3d/hooks/useProjectionToggle.ts
Normal file
330
apps/web/src/widgets/map3d/hooks/useProjectionToggle.ts
Normal file
@ -0,0 +1,330 @@
|
||||
import { useCallback, useEffect, useRef, type MutableRefObject } from 'react';
|
||||
import type maplibregl from 'maplibre-gl';
|
||||
import { MapboxOverlay } from '@deck.gl/mapbox';
|
||||
import { MaplibreDeckCustomLayer } from '../MaplibreDeckCustomLayer';
|
||||
import type { MapProjectionId } from '../types';
|
||||
import { DECK_VIEW_ID } from '../constants';
|
||||
import { kickRepaint, onMapStyleReady, extractProjectionType } from '../lib/mapCore';
|
||||
import { removeLayerIfExists } from '../lib/layerHelpers';
|
||||
|
||||
export function useProjectionToggle(
|
||||
mapRef: MutableRefObject<maplibregl.Map | null>,
|
||||
overlayRef: MutableRefObject<MapboxOverlay | null>,
|
||||
overlayInteractionRef: MutableRefObject<MapboxOverlay | null>,
|
||||
globeDeckLayerRef: MutableRefObject<MaplibreDeckCustomLayer | null>,
|
||||
projectionBusyRef: MutableRefObject<boolean>,
|
||||
opts: {
|
||||
projection: MapProjectionId;
|
||||
clearGlobeNativeLayers: () => void;
|
||||
ensureMercatorOverlay: () => MapboxOverlay | null;
|
||||
onProjectionLoadingChange?: (loading: boolean) => void;
|
||||
pulseMapSync: () => void;
|
||||
setMapSyncEpoch: (updater: (prev: number) => number) => void;
|
||||
},
|
||||
): () => void {
|
||||
const { projection, clearGlobeNativeLayers, ensureMercatorOverlay, onProjectionLoadingChange, pulseMapSync, setMapSyncEpoch } = opts;
|
||||
|
||||
const projectionBusyTokenRef = useRef(0);
|
||||
const projectionBusyTimerRef = useRef<ReturnType<typeof window.setTimeout> | null>(null);
|
||||
const projectionPrevRef = useRef<MapProjectionId>(projection);
|
||||
const projectionRef = useRef<MapProjectionId>(projection);
|
||||
|
||||
useEffect(() => {
|
||||
projectionRef.current = projection;
|
||||
}, [projection]);
|
||||
|
||||
const clearProjectionBusyTimer = useCallback(() => {
|
||||
if (projectionBusyTimerRef.current == null) return;
|
||||
clearTimeout(projectionBusyTimerRef.current);
|
||||
projectionBusyTimerRef.current = null;
|
||||
}, []);
|
||||
|
||||
// eslint-disable-next-line react-hooks/preserve-manual-memoization
|
||||
const endProjectionLoading = useCallback(() => {
|
||||
if (!projectionBusyRef.current) return;
|
||||
projectionBusyRef.current = false;
|
||||
clearProjectionBusyTimer();
|
||||
if (onProjectionLoadingChange) {
|
||||
onProjectionLoadingChange(false);
|
||||
}
|
||||
setMapSyncEpoch((prev) => prev + 1);
|
||||
kickRepaint(mapRef.current);
|
||||
}, [clearProjectionBusyTimer, onProjectionLoadingChange, setMapSyncEpoch]);
|
||||
|
||||
const setProjectionLoading = useCallback(
|
||||
// eslint-disable-next-line react-hooks/preserve-manual-memoization
|
||||
(loading: boolean) => {
|
||||
if (projectionBusyRef.current === loading) return;
|
||||
if (!loading) {
|
||||
endProjectionLoading();
|
||||
return;
|
||||
}
|
||||
|
||||
clearProjectionBusyTimer();
|
||||
projectionBusyRef.current = true;
|
||||
const token = ++projectionBusyTokenRef.current;
|
||||
if (onProjectionLoadingChange) {
|
||||
onProjectionLoadingChange(true);
|
||||
}
|
||||
|
||||
projectionBusyTimerRef.current = setTimeout(() => {
|
||||
if (!projectionBusyRef.current || projectionBusyTokenRef.current !== token) return;
|
||||
console.debug('Projection loading fallback timeout reached.');
|
||||
endProjectionLoading();
|
||||
}, 4000);
|
||||
},
|
||||
[clearProjectionBusyTimer, endProjectionLoading, onProjectionLoadingChange],
|
||||
);
|
||||
|
||||
useEffect(() => {
|
||||
return () => {
|
||||
clearProjectionBusyTimer();
|
||||
endProjectionLoading();
|
||||
};
|
||||
}, [clearProjectionBusyTimer, endProjectionLoading]);
|
||||
|
||||
// eslint-disable-next-line react-hooks/preserve-manual-memoization
|
||||
const reorderGlobeFeatureLayers = useCallback(() => {
|
||||
const map = mapRef.current;
|
||||
if (!map || projectionRef.current !== 'globe') return;
|
||||
if (projectionBusyRef.current) return;
|
||||
if (!map.isStyleLoaded()) return;
|
||||
|
||||
const ordering = [
|
||||
'subcables-hitarea',
|
||||
'subcables-casing',
|
||||
'subcables-line',
|
||||
'subcables-glow',
|
||||
'subcables-points',
|
||||
'subcables-label',
|
||||
'zones-fill',
|
||||
'zones-line',
|
||||
'zones-label',
|
||||
'predict-vectors-outline',
|
||||
'predict-vectors',
|
||||
'predict-vectors-hl-outline',
|
||||
'predict-vectors-hl',
|
||||
'ships-globe-halo',
|
||||
'ships-globe-outline',
|
||||
'ships-globe',
|
||||
'ships-globe-label',
|
||||
'ships-globe-hover-halo',
|
||||
'ships-globe-hover-outline',
|
||||
'ships-globe-hover',
|
||||
'pair-lines-ml',
|
||||
'fc-lines-ml',
|
||||
'pair-range-ml',
|
||||
'fleet-circles-ml-fill',
|
||||
'fleet-circles-ml',
|
||||
];
|
||||
|
||||
for (const layerId of ordering) {
|
||||
try {
|
||||
if (map.getLayer(layerId)) map.moveLayer(layerId);
|
||||
} catch {
|
||||
// ignore
|
||||
}
|
||||
}
|
||||
|
||||
kickRepaint(map);
|
||||
}, []);
|
||||
|
||||
// Projection toggle (mercator <-> globe)
|
||||
useEffect(() => {
|
||||
const map = mapRef.current;
|
||||
if (!map) return;
|
||||
let cancelled = false;
|
||||
let retries = 0;
|
||||
const maxRetries = 18;
|
||||
const isTransition = projectionPrevRef.current !== projection;
|
||||
projectionPrevRef.current = projection;
|
||||
let settleScheduled = false;
|
||||
let settleCleanup: (() => void) | null = null;
|
||||
|
||||
const startProjectionSettle = () => {
|
||||
if (!isTransition || settleScheduled) return;
|
||||
settleScheduled = true;
|
||||
|
||||
const finalize = () => {
|
||||
if (!cancelled && isTransition) setProjectionLoading(false);
|
||||
};
|
||||
|
||||
const finalizeSoon = () => {
|
||||
if (cancelled || !isTransition || projectionBusyRef.current === false) return;
|
||||
if (!map.isStyleLoaded()) {
|
||||
requestAnimationFrame(finalizeSoon);
|
||||
return;
|
||||
}
|
||||
requestAnimationFrame(finalize);
|
||||
};
|
||||
|
||||
const onIdle = () => finalizeSoon();
|
||||
try {
|
||||
map.on('idle', onIdle);
|
||||
const styleReadyCleanup = onMapStyleReady(map, finalizeSoon);
|
||||
settleCleanup = () => {
|
||||
map.off('idle', onIdle);
|
||||
styleReadyCleanup();
|
||||
};
|
||||
} catch {
|
||||
requestAnimationFrame(finalize);
|
||||
settleCleanup = null;
|
||||
}
|
||||
|
||||
finalizeSoon();
|
||||
};
|
||||
|
||||
if (isTransition) setProjectionLoading(true);
|
||||
|
||||
const disposeMercatorOverlays = () => {
|
||||
const disposeOne = (target: MapboxOverlay | null, toNull: 'base' | 'interaction') => {
|
||||
if (!target) return;
|
||||
try {
|
||||
target.setProps({ layers: [] } as never);
|
||||
} catch {
|
||||
// ignore
|
||||
}
|
||||
try {
|
||||
map.removeControl(target as never);
|
||||
} catch {
|
||||
// ignore
|
||||
}
|
||||
try {
|
||||
target.finalize();
|
||||
} catch {
|
||||
// ignore
|
||||
}
|
||||
if (toNull === 'base') {
|
||||
overlayRef.current = null;
|
||||
} else {
|
||||
overlayInteractionRef.current = null;
|
||||
}
|
||||
};
|
||||
|
||||
disposeOne(overlayRef.current, 'base');
|
||||
disposeOne(overlayInteractionRef.current, 'interaction');
|
||||
};
|
||||
|
||||
const disposeGlobeDeckLayer = () => {
|
||||
const current = globeDeckLayerRef.current;
|
||||
if (!current) return;
|
||||
removeLayerIfExists(map, current.id);
|
||||
try {
|
||||
current.requestFinalize();
|
||||
} catch {
|
||||
// ignore
|
||||
}
|
||||
globeDeckLayerRef.current = null;
|
||||
};
|
||||
|
||||
const syncProjectionAndDeck = () => {
|
||||
if (cancelled) return;
|
||||
if (!isTransition) {
|
||||
return;
|
||||
}
|
||||
|
||||
if (!map.isStyleLoaded()) {
|
||||
if (!cancelled && retries < maxRetries) {
|
||||
retries += 1;
|
||||
window.requestAnimationFrame(() => syncProjectionAndDeck());
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
const next = projection;
|
||||
const currentProjection = extractProjectionType(map);
|
||||
const shouldSwitchProjection = currentProjection !== next;
|
||||
|
||||
if (projection === 'globe') {
|
||||
disposeMercatorOverlays();
|
||||
clearGlobeNativeLayers();
|
||||
} else {
|
||||
disposeGlobeDeckLayer();
|
||||
clearGlobeNativeLayers();
|
||||
}
|
||||
|
||||
try {
|
||||
if (shouldSwitchProjection) {
|
||||
map.setProjection({ type: next });
|
||||
}
|
||||
map.setRenderWorldCopies(next !== 'globe');
|
||||
if (shouldSwitchProjection && currentProjection !== next && !cancelled && retries < maxRetries) {
|
||||
retries += 1;
|
||||
window.requestAnimationFrame(() => syncProjectionAndDeck());
|
||||
return;
|
||||
}
|
||||
} catch (e) {
|
||||
if (!cancelled && retries < maxRetries) {
|
||||
retries += 1;
|
||||
window.requestAnimationFrame(() => syncProjectionAndDeck());
|
||||
return;
|
||||
}
|
||||
if (isTransition) setProjectionLoading(false);
|
||||
console.warn('Projection switch failed:', e);
|
||||
}
|
||||
|
||||
if (projection === 'globe') {
|
||||
disposeGlobeDeckLayer();
|
||||
|
||||
if (!globeDeckLayerRef.current) {
|
||||
globeDeckLayerRef.current = new MaplibreDeckCustomLayer({
|
||||
id: 'deck-globe',
|
||||
viewId: DECK_VIEW_ID,
|
||||
deckProps: { layers: [] },
|
||||
});
|
||||
}
|
||||
|
||||
const layer = globeDeckLayerRef.current;
|
||||
const layerId = layer?.id;
|
||||
if (layer && layerId && map.isStyleLoaded() && !map.getLayer(layerId)) {
|
||||
try {
|
||||
map.addLayer(layer);
|
||||
} catch {
|
||||
// ignore
|
||||
}
|
||||
if (!map.getLayer(layerId) && !cancelled && retries < maxRetries) {
|
||||
retries += 1;
|
||||
window.requestAnimationFrame(() => syncProjectionAndDeck());
|
||||
return;
|
||||
}
|
||||
}
|
||||
} else {
|
||||
disposeGlobeDeckLayer();
|
||||
ensureMercatorOverlay();
|
||||
}
|
||||
|
||||
reorderGlobeFeatureLayers();
|
||||
kickRepaint(map);
|
||||
try {
|
||||
map.resize();
|
||||
} catch {
|
||||
// ignore
|
||||
}
|
||||
if (isTransition) {
|
||||
startProjectionSettle();
|
||||
}
|
||||
pulseMapSync();
|
||||
};
|
||||
|
||||
if (!isTransition) return;
|
||||
|
||||
if (map.isStyleLoaded()) syncProjectionAndDeck();
|
||||
else {
|
||||
const stop = onMapStyleReady(map, syncProjectionAndDeck);
|
||||
return () => {
|
||||
cancelled = true;
|
||||
if (settleCleanup) settleCleanup();
|
||||
stop();
|
||||
if (isTransition) setProjectionLoading(false);
|
||||
};
|
||||
}
|
||||
|
||||
return () => {
|
||||
cancelled = true;
|
||||
if (settleCleanup) settleCleanup();
|
||||
if (isTransition) setProjectionLoading(false);
|
||||
};
|
||||
}, [projection, clearGlobeNativeLayers, ensureMercatorOverlay, reorderGlobeFeatureLayers, setProjectionLoading]);
|
||||
|
||||
return reorderGlobeFeatureLayers;
|
||||
}
|
||||
291
apps/web/src/widgets/map3d/hooks/useSubcablesLayer.ts
Normal file
291
apps/web/src/widgets/map3d/hooks/useSubcablesLayer.ts
Normal file
@ -0,0 +1,291 @@
|
||||
import { useEffect, useMemo, useRef, type MutableRefObject } from 'react';
|
||||
import maplibregl from 'maplibre-gl';
|
||||
import type { SubcableGeoJson } from '../../../entities/subcable/model/types';
|
||||
import type { MapToggleState } from '../../../features/mapToggles/MapToggles';
|
||||
import type { MapProjectionId } from '../types';
|
||||
import { kickRepaint } from '../lib/mapCore';
|
||||
import { useNativeMapLayers, type NativeLayerSpec } from './useNativeMapLayers';
|
||||
|
||||
/* ── Layer / Source IDs ─────────────────────────────────────────────── */
|
||||
const SRC_ID = 'subcables-src';
|
||||
const POINTS_SRC_ID = 'subcables-pts-src';
|
||||
|
||||
const HITAREA_ID = 'subcables-hitarea';
|
||||
const CASING_ID = 'subcables-casing';
|
||||
const LINE_ID = 'subcables-line';
|
||||
const GLOW_ID = 'subcables-glow';
|
||||
const POINTS_ID = 'subcables-points';
|
||||
const LABEL_ID = 'subcables-label';
|
||||
|
||||
/* ── Paint defaults (used for layer creation + hover reset) ──────── */
|
||||
const LINE_OPACITY_DEFAULT = ['interpolate', ['linear'], ['zoom'], 2, 0.7, 6, 0.82, 10, 0.92];
|
||||
const LINE_WIDTH_DEFAULT = ['interpolate', ['linear'], ['zoom'], 2, 1.6, 6, 2.5, 10, 4.0];
|
||||
const CASING_OPACITY_DEFAULT = ['interpolate', ['linear'], ['zoom'], 2, 0.3, 5, 0.5, 8, 0.65];
|
||||
const CASING_WIDTH_DEFAULT = ['interpolate', ['linear'], ['zoom'], 2, 2.4, 6, 3.6, 10, 5.5];
|
||||
const POINTS_OPACITY_DEFAULT = ['interpolate', ['linear'], ['zoom'], 2, 0.5, 5, 0.7, 8, 0.85];
|
||||
const POINTS_RADIUS_DEFAULT = ['interpolate', ['linear'], ['zoom'], 2, 1.5, 6, 2.5, 10, 4];
|
||||
|
||||
/* ── Layer specifications ────────────────────────────────────────── */
|
||||
const LAYER_SPECS: NativeLayerSpec[] = [
|
||||
{
|
||||
id: HITAREA_ID,
|
||||
type: 'line',
|
||||
sourceId: SRC_ID,
|
||||
paint: { 'line-color': 'rgba(0,0,0,0)', 'line-width': 14, 'line-opacity': 0 },
|
||||
layout: { 'line-cap': 'round', 'line-join': 'round' },
|
||||
minzoom: 3,
|
||||
},
|
||||
{
|
||||
id: CASING_ID,
|
||||
type: 'line',
|
||||
sourceId: SRC_ID,
|
||||
paint: {
|
||||
'line-color': 'rgba(0,0,0,0.55)',
|
||||
'line-width': CASING_WIDTH_DEFAULT,
|
||||
'line-opacity': CASING_OPACITY_DEFAULT,
|
||||
},
|
||||
layout: { 'line-cap': 'round', 'line-join': 'round' },
|
||||
minzoom: 3,
|
||||
},
|
||||
{
|
||||
id: LINE_ID,
|
||||
type: 'line',
|
||||
sourceId: SRC_ID,
|
||||
paint: {
|
||||
'line-color': ['get', 'color'],
|
||||
'line-opacity': LINE_OPACITY_DEFAULT,
|
||||
'line-width': LINE_WIDTH_DEFAULT,
|
||||
},
|
||||
layout: { 'line-cap': 'round', 'line-join': 'round' },
|
||||
},
|
||||
{
|
||||
id: GLOW_ID,
|
||||
type: 'line',
|
||||
sourceId: SRC_ID,
|
||||
paint: {
|
||||
'line-color': ['get', 'color'],
|
||||
'line-opacity': 0,
|
||||
'line-width': ['interpolate', ['linear'], ['zoom'], 2, 5, 6, 8, 10, 12],
|
||||
'line-blur': ['interpolate', ['linear'], ['zoom'], 2, 3, 6, 5, 10, 7],
|
||||
},
|
||||
filter: ['==', ['get', 'id'], ''],
|
||||
layout: { 'line-cap': 'round', 'line-join': 'round' },
|
||||
minzoom: 3,
|
||||
},
|
||||
{
|
||||
id: POINTS_ID,
|
||||
type: 'circle',
|
||||
sourceId: POINTS_SRC_ID,
|
||||
paint: {
|
||||
'circle-radius': POINTS_RADIUS_DEFAULT,
|
||||
'circle-color': ['get', 'color'],
|
||||
'circle-opacity': POINTS_OPACITY_DEFAULT,
|
||||
'circle-stroke-color': 'rgba(0,0,0,0.5)',
|
||||
'circle-stroke-width': 0.5,
|
||||
},
|
||||
minzoom: 3,
|
||||
},
|
||||
{
|
||||
id: LABEL_ID,
|
||||
type: 'symbol',
|
||||
sourceId: SRC_ID,
|
||||
paint: {
|
||||
'text-color': 'rgba(220,232,245,0.82)',
|
||||
'text-halo-color': 'rgba(2,6,23,0.9)',
|
||||
'text-halo-width': 1.2,
|
||||
'text-halo-blur': 0.5,
|
||||
'text-opacity': ['interpolate', ['linear'], ['zoom'], 4, 0, 5, 0.7, 8, 0.88],
|
||||
},
|
||||
layout: {
|
||||
'symbol-placement': 'line',
|
||||
'text-field': ['get', 'name'],
|
||||
'text-size': ['interpolate', ['linear'], ['zoom'], 4, 9, 8, 11, 12, 13],
|
||||
'text-font': ['Noto Sans Regular', 'Open Sans Regular'],
|
||||
'text-allow-overlap': false,
|
||||
'text-padding': 8,
|
||||
'text-rotation-alignment': 'map',
|
||||
},
|
||||
minzoom: 4,
|
||||
},
|
||||
];
|
||||
|
||||
export function useSubcablesLayer(
|
||||
mapRef: MutableRefObject<maplibregl.Map | null>,
|
||||
projectionBusyRef: MutableRefObject<boolean>,
|
||||
reorderGlobeFeatureLayers: () => void,
|
||||
opts: {
|
||||
subcableGeo: SubcableGeoJson | null;
|
||||
overlays: MapToggleState;
|
||||
projection: MapProjectionId;
|
||||
mapSyncEpoch: number;
|
||||
hoveredCableId: string | null;
|
||||
onHoverCable: (cableId: string | null) => void;
|
||||
onClickCable: (cableId: string | null) => void;
|
||||
},
|
||||
) {
|
||||
const { subcableGeo, overlays, projection, mapSyncEpoch, hoveredCableId, onHoverCable, onClickCable } = opts;
|
||||
|
||||
const onHoverRef = useRef(onHoverCable);
|
||||
const onClickRef = useRef(onClickCable);
|
||||
const hoveredCableIdRef = useRef(hoveredCableId);
|
||||
useEffect(() => {
|
||||
onHoverRef.current = onHoverCable;
|
||||
onClickRef.current = onClickCable;
|
||||
hoveredCableIdRef.current = hoveredCableId;
|
||||
});
|
||||
|
||||
/* ── Derived point features ──────────────────────────────────────── */
|
||||
const pointsGeoJson = useMemo<GeoJSON.FeatureCollection>(() => {
|
||||
if (!subcableGeo) return { type: 'FeatureCollection', features: [] };
|
||||
const features: GeoJSON.Feature<GeoJSON.Point>[] = [];
|
||||
for (const f of subcableGeo.features) {
|
||||
const coords = f.properties.coordinates;
|
||||
if (!coords || coords.length < 2) continue;
|
||||
features.push({
|
||||
type: 'Feature',
|
||||
properties: { id: f.properties.id, color: f.properties.color },
|
||||
geometry: { type: 'Point', coordinates: coords },
|
||||
});
|
||||
}
|
||||
return { type: 'FeatureCollection', features };
|
||||
}, [subcableGeo]);
|
||||
|
||||
/* ================================================================
|
||||
* Effect 1: Layer creation & data update (via useNativeMapLayers)
|
||||
* ================================================================ */
|
||||
useNativeMapLayers(
|
||||
mapRef,
|
||||
projectionBusyRef,
|
||||
reorderGlobeFeatureLayers,
|
||||
{
|
||||
sources: [
|
||||
{ id: SRC_ID, data: subcableGeo, options: { tolerance: 1, buffer: 64 } },
|
||||
{ id: POINTS_SRC_ID, data: pointsGeoJson },
|
||||
],
|
||||
layers: LAYER_SPECS,
|
||||
visible: overlays.subcables,
|
||||
beforeLayer: ['zones-fill', 'deck-globe'],
|
||||
onAfterSetup: (map) => applyHoverHighlight(map, hoveredCableIdRef.current),
|
||||
},
|
||||
[subcableGeo, pointsGeoJson, overlays.subcables, projection, mapSyncEpoch, reorderGlobeFeatureLayers],
|
||||
);
|
||||
|
||||
/* ================================================================
|
||||
* Effect 2: Hover highlight (paint-only, no layer creation)
|
||||
* ================================================================ */
|
||||
useEffect(() => {
|
||||
const map = mapRef.current;
|
||||
if (!map || !map.isStyleLoaded()) return;
|
||||
if (projectionBusyRef.current) return;
|
||||
if (!map.getLayer(LINE_ID)) return;
|
||||
|
||||
applyHoverHighlight(map, hoveredCableId);
|
||||
kickRepaint(map);
|
||||
}, [hoveredCableId]);
|
||||
|
||||
/* ================================================================
|
||||
* Effect 3: Mouse events (bind to hit-area for easy hovering)
|
||||
* ================================================================ */
|
||||
useEffect(() => {
|
||||
const map = mapRef.current;
|
||||
if (!map) return;
|
||||
if (!overlays.subcables) return;
|
||||
|
||||
let cancelled = false;
|
||||
let retryTimer: ReturnType<typeof setTimeout> | null = null;
|
||||
|
||||
const onMouseMove = (e: maplibregl.MapMouseEvent & { features?: maplibregl.MapGeoJSONFeature[] }) => {
|
||||
const cableId = e.features?.[0]?.properties?.id;
|
||||
if (typeof cableId === 'string' && cableId) {
|
||||
map.getCanvas().style.cursor = 'pointer';
|
||||
onHoverRef.current(cableId);
|
||||
}
|
||||
};
|
||||
|
||||
const onMouseLeave = () => {
|
||||
map.getCanvas().style.cursor = '';
|
||||
onHoverRef.current(null);
|
||||
};
|
||||
|
||||
const onClick = (e: maplibregl.MapMouseEvent & { features?: maplibregl.MapGeoJSONFeature[] }) => {
|
||||
const cableId = e.features?.[0]?.properties?.id;
|
||||
if (typeof cableId === 'string' && cableId) {
|
||||
onClickRef.current(cableId);
|
||||
}
|
||||
};
|
||||
|
||||
const bindEvents = () => {
|
||||
if (cancelled) return;
|
||||
const targetLayer = map.getLayer(HITAREA_ID) ? HITAREA_ID : map.getLayer(LINE_ID) ? LINE_ID : null;
|
||||
if (!targetLayer) {
|
||||
retryTimer = setTimeout(bindEvents, 200);
|
||||
return;
|
||||
}
|
||||
map.on('mousemove', targetLayer, onMouseMove);
|
||||
map.on('mouseleave', targetLayer, onMouseLeave);
|
||||
map.on('click', targetLayer, onClick);
|
||||
};
|
||||
|
||||
if (map.isStyleLoaded()) {
|
||||
bindEvents();
|
||||
} else {
|
||||
map.once('idle', bindEvents);
|
||||
}
|
||||
|
||||
return () => {
|
||||
cancelled = true;
|
||||
if (retryTimer) clearTimeout(retryTimer);
|
||||
try {
|
||||
map.off('mousemove', HITAREA_ID, onMouseMove);
|
||||
map.off('mouseleave', HITAREA_ID, onMouseLeave);
|
||||
map.off('click', HITAREA_ID, onClick);
|
||||
map.off('mousemove', LINE_ID, onMouseMove);
|
||||
map.off('mouseleave', LINE_ID, onMouseLeave);
|
||||
map.off('click', LINE_ID, onClick);
|
||||
} catch {
|
||||
// ignore
|
||||
}
|
||||
};
|
||||
}, [overlays.subcables, mapSyncEpoch]);
|
||||
}
|
||||
|
||||
/* ── Hover highlight helper (paint-only mutations) ────────────────── */
|
||||
function applyHoverHighlight(map: maplibregl.Map, hoveredId: string | null) {
|
||||
if (hoveredId) {
|
||||
const matchExpr = ['==', ['get', 'id'], hoveredId];
|
||||
|
||||
if (map.getLayer(LINE_ID)) {
|
||||
map.setPaintProperty(LINE_ID, 'line-opacity', ['case', matchExpr, 1.0, 0.2] as never);
|
||||
map.setPaintProperty(LINE_ID, 'line-width', ['case', matchExpr, 4.5, 1.2] as never);
|
||||
}
|
||||
if (map.getLayer(CASING_ID)) {
|
||||
map.setPaintProperty(CASING_ID, 'line-opacity', ['case', matchExpr, 0.7, 0.12] as never);
|
||||
map.setPaintProperty(CASING_ID, 'line-width', ['case', matchExpr, 6.5, 2.0] as never);
|
||||
}
|
||||
if (map.getLayer(GLOW_ID)) {
|
||||
map.setFilter(GLOW_ID, matchExpr as never);
|
||||
map.setPaintProperty(GLOW_ID, 'line-opacity', 0.35);
|
||||
}
|
||||
if (map.getLayer(POINTS_ID)) {
|
||||
map.setPaintProperty(POINTS_ID, 'circle-opacity', ['case', matchExpr, 1.0, 0.15] as never);
|
||||
map.setPaintProperty(POINTS_ID, 'circle-radius', ['case', matchExpr, 4, 1.5] as never);
|
||||
}
|
||||
} else {
|
||||
if (map.getLayer(LINE_ID)) {
|
||||
map.setPaintProperty(LINE_ID, 'line-opacity', LINE_OPACITY_DEFAULT as never);
|
||||
map.setPaintProperty(LINE_ID, 'line-width', LINE_WIDTH_DEFAULT as never);
|
||||
}
|
||||
if (map.getLayer(CASING_ID)) {
|
||||
map.setPaintProperty(CASING_ID, 'line-opacity', CASING_OPACITY_DEFAULT as never);
|
||||
map.setPaintProperty(CASING_ID, 'line-width', CASING_WIDTH_DEFAULT as never);
|
||||
}
|
||||
if (map.getLayer(GLOW_ID)) {
|
||||
map.setFilter(GLOW_ID, ['==', ['get', 'id'], ''] as never);
|
||||
map.setPaintProperty(GLOW_ID, 'line-opacity', 0);
|
||||
}
|
||||
if (map.getLayer(POINTS_ID)) {
|
||||
map.setPaintProperty(POINTS_ID, 'circle-opacity', POINTS_OPACITY_DEFAULT as never);
|
||||
map.setPaintProperty(POINTS_ID, 'circle-radius', POINTS_RADIUS_DEFAULT as never);
|
||||
}
|
||||
}
|
||||
}
|
||||
230
apps/web/src/widgets/map3d/hooks/useZonesLayer.ts
Normal file
230
apps/web/src/widgets/map3d/hooks/useZonesLayer.ts
Normal file
@ -0,0 +1,230 @@
|
||||
import { useEffect, type MutableRefObject } from 'react';
|
||||
import maplibregl, {
|
||||
type GeoJSONSource,
|
||||
type GeoJSONSourceSpecification,
|
||||
type LayerSpecification,
|
||||
} from 'maplibre-gl';
|
||||
import type { ZoneId } from '../../../entities/zone/model/meta';
|
||||
import { ZONE_META } from '../../../entities/zone/model/meta';
|
||||
import type { ZonesGeoJson } from '../../../entities/zone/api/useZones';
|
||||
import type { MapToggleState } from '../../../features/mapToggles/MapToggles';
|
||||
import type { BaseMapId, MapProjectionId } from '../types';
|
||||
import { kickRepaint, onMapStyleReady } from '../lib/mapCore';
|
||||
|
||||
export function useZonesLayer(
|
||||
mapRef: MutableRefObject<maplibregl.Map | null>,
|
||||
projectionBusyRef: MutableRefObject<boolean>,
|
||||
reorderGlobeFeatureLayers: () => void,
|
||||
opts: {
|
||||
zones: ZonesGeoJson | null;
|
||||
overlays: MapToggleState;
|
||||
projection: MapProjectionId;
|
||||
baseMap: BaseMapId;
|
||||
hoveredZoneId: string | null;
|
||||
mapSyncEpoch: number;
|
||||
},
|
||||
) {
|
||||
const { zones, overlays, projection, baseMap, hoveredZoneId, mapSyncEpoch } = opts;
|
||||
|
||||
useEffect(() => {
|
||||
const map = mapRef.current;
|
||||
if (!map) return;
|
||||
|
||||
const srcId = 'zones-src';
|
||||
const fillId = 'zones-fill';
|
||||
const lineId = 'zones-line';
|
||||
const labelId = 'zones-label';
|
||||
|
||||
const zoneColorExpr: unknown[] = ['match', ['get', 'zoneId']];
|
||||
for (const k of Object.keys(ZONE_META) as ZoneId[]) {
|
||||
zoneColorExpr.push(k, ZONE_META[k].color);
|
||||
}
|
||||
zoneColorExpr.push('#3B82F6');
|
||||
const zoneLabelExpr: unknown[] = ['match', ['to-string', ['coalesce', ['get', 'zoneId'], '']]];
|
||||
for (const k of Object.keys(ZONE_META) as ZoneId[]) {
|
||||
zoneLabelExpr.push(k, ZONE_META[k].name);
|
||||
}
|
||||
zoneLabelExpr.push(['coalesce', ['get', 'zoneName'], ['get', 'zoneLabel'], ['get', 'NAME'], '수역']);
|
||||
|
||||
const ensure = () => {
|
||||
if (projectionBusyRef.current) return;
|
||||
const visibility = overlays.zones ? 'visible' : 'none';
|
||||
try {
|
||||
if (map.getLayer(fillId)) map.setLayoutProperty(fillId, 'visibility', visibility);
|
||||
} catch {
|
||||
// ignore
|
||||
}
|
||||
try {
|
||||
if (map.getLayer(lineId)) map.setLayoutProperty(lineId, 'visibility', visibility);
|
||||
} catch {
|
||||
// ignore
|
||||
}
|
||||
try {
|
||||
if (map.getLayer(labelId)) map.setLayoutProperty(labelId, 'visibility', visibility);
|
||||
} catch {
|
||||
// ignore
|
||||
}
|
||||
|
||||
if (!zones) return;
|
||||
if (!map.isStyleLoaded()) return;
|
||||
|
||||
try {
|
||||
const existing = map.getSource(srcId) as GeoJSONSource | undefined;
|
||||
if (existing) {
|
||||
existing.setData(zones);
|
||||
} else {
|
||||
map.addSource(srcId, { type: 'geojson', data: zones } as GeoJSONSourceSpecification);
|
||||
}
|
||||
|
||||
const style = map.getStyle();
|
||||
const styleLayers = style && Array.isArray(style.layers) ? style.layers : [];
|
||||
const firstSymbol = styleLayers.find((l) => (l as { type?: string } | undefined)?.type === 'symbol') as
|
||||
| { id?: string }
|
||||
| undefined;
|
||||
const before = map.getLayer('deck-globe')
|
||||
? 'deck-globe'
|
||||
: map.getLayer('ships')
|
||||
? 'ships'
|
||||
: map.getLayer('seamark')
|
||||
? 'seamark'
|
||||
: firstSymbol?.id;
|
||||
|
||||
const zoneMatchExpr =
|
||||
hoveredZoneId !== null
|
||||
? (['==', ['to-string', ['coalesce', ['get', 'zoneId'], '']], hoveredZoneId] as unknown[])
|
||||
: false;
|
||||
const zoneLineWidthExpr = hoveredZoneId
|
||||
? ([
|
||||
'interpolate',
|
||||
['linear'],
|
||||
['zoom'],
|
||||
4,
|
||||
['case', zoneMatchExpr, 1.6, 0.8],
|
||||
10,
|
||||
['case', zoneMatchExpr, 2.0, 1.4],
|
||||
14,
|
||||
['case', zoneMatchExpr, 2.8, 2.1],
|
||||
] as unknown as never)
|
||||
: (['interpolate', ['linear'], ['zoom'], 4, 0.8, 10, 1.4, 14, 2.1] as never);
|
||||
|
||||
if (map.getLayer(fillId)) {
|
||||
try {
|
||||
map.setPaintProperty(
|
||||
fillId,
|
||||
'fill-opacity',
|
||||
hoveredZoneId ? (['case', zoneMatchExpr, 0.24, 0.1] as unknown as number) : 0.12,
|
||||
);
|
||||
} catch {
|
||||
// ignore
|
||||
}
|
||||
}
|
||||
|
||||
if (map.getLayer(lineId)) {
|
||||
try {
|
||||
map.setPaintProperty(
|
||||
lineId,
|
||||
'line-color',
|
||||
hoveredZoneId
|
||||
? (['case', zoneMatchExpr, 'rgba(125,211,252,0.98)', zoneColorExpr as never] as never)
|
||||
: (zoneColorExpr as never),
|
||||
);
|
||||
} catch {
|
||||
// ignore
|
||||
}
|
||||
try {
|
||||
map.setPaintProperty(lineId, 'line-opacity', hoveredZoneId ? (['case', zoneMatchExpr, 1, 0.85] as never) : 0.85);
|
||||
} catch {
|
||||
// ignore
|
||||
}
|
||||
try {
|
||||
map.setPaintProperty(lineId, 'line-width', zoneLineWidthExpr);
|
||||
} catch {
|
||||
// ignore
|
||||
}
|
||||
}
|
||||
|
||||
if (!map.getLayer(fillId)) {
|
||||
map.addLayer(
|
||||
{
|
||||
id: fillId,
|
||||
type: 'fill',
|
||||
source: srcId,
|
||||
paint: {
|
||||
'fill-color': zoneColorExpr as never,
|
||||
'fill-opacity': hoveredZoneId
|
||||
? ([
|
||||
'case',
|
||||
zoneMatchExpr,
|
||||
0.24,
|
||||
0.1,
|
||||
] as unknown as number)
|
||||
: 0.12,
|
||||
},
|
||||
layout: { visibility },
|
||||
} as unknown as LayerSpecification,
|
||||
before,
|
||||
);
|
||||
}
|
||||
|
||||
if (!map.getLayer(lineId)) {
|
||||
map.addLayer(
|
||||
{
|
||||
id: lineId,
|
||||
type: 'line',
|
||||
source: srcId,
|
||||
paint: {
|
||||
'line-color': hoveredZoneId
|
||||
? (['case', zoneMatchExpr, 'rgba(125,211,252,0.98)', zoneColorExpr as never] as never)
|
||||
: (zoneColorExpr as never),
|
||||
'line-opacity': hoveredZoneId
|
||||
? (['case', zoneMatchExpr, 1, 0.85] as never)
|
||||
: 0.85,
|
||||
'line-width': zoneLineWidthExpr,
|
||||
},
|
||||
layout: { visibility },
|
||||
} as unknown as LayerSpecification,
|
||||
before,
|
||||
);
|
||||
}
|
||||
|
||||
if (!map.getLayer(labelId)) {
|
||||
map.addLayer(
|
||||
{
|
||||
id: labelId,
|
||||
type: 'symbol',
|
||||
source: srcId,
|
||||
layout: {
|
||||
visibility,
|
||||
'symbol-placement': 'point',
|
||||
'text-field': zoneLabelExpr as never,
|
||||
'text-size': 11,
|
||||
'text-font': ['Noto Sans Regular', 'Open Sans Regular'],
|
||||
'text-anchor': 'top',
|
||||
'text-offset': [0, 0.35],
|
||||
'text-allow-overlap': false,
|
||||
'text-ignore-placement': false,
|
||||
},
|
||||
paint: {
|
||||
'text-color': '#dbeafe',
|
||||
'text-halo-color': 'rgba(2,6,23,0.85)',
|
||||
'text-halo-width': 1.2,
|
||||
'text-halo-blur': 0.8,
|
||||
},
|
||||
} as unknown as LayerSpecification,
|
||||
undefined,
|
||||
);
|
||||
}
|
||||
} catch (e) {
|
||||
console.warn('Zones layer setup failed:', e);
|
||||
} finally {
|
||||
reorderGlobeFeatureLayers();
|
||||
kickRepaint(map);
|
||||
}
|
||||
};
|
||||
|
||||
const stop = onMapStyleReady(map, ensure);
|
||||
return () => {
|
||||
stop();
|
||||
};
|
||||
}, [zones, overlays.zones, projection, baseMap, hoveredZoneId, mapSyncEpoch, reorderGlobeFeatureLayers]);
|
||||
}
|
||||
311
apps/web/src/widgets/map3d/layers/bathymetry.ts
Normal file
311
apps/web/src/widgets/map3d/layers/bathymetry.ts
Normal file
@ -0,0 +1,311 @@
|
||||
import maplibregl, {
|
||||
type LayerSpecification,
|
||||
type StyleSpecification,
|
||||
type VectorSourceSpecification,
|
||||
} from 'maplibre-gl';
|
||||
import type { BaseMapId, BathyZoomRange, MapProjectionId } from '../types';
|
||||
import { getLayerId, getMapTilerKey } from '../lib/mapCore';
|
||||
|
||||
export const SHALLOW_WATER_FILL_DEFAULT = '#14606e';
|
||||
export const SHALLOW_WATER_LINE_DEFAULT = '#114f5c';
|
||||
|
||||
const BATHY_ZOOM_RANGES: BathyZoomRange[] = [
|
||||
{ id: 'bathymetry-fill', mercator: [3, 24], globe: [3, 24] },
|
||||
{ id: 'bathymetry-borders', mercator: [5, 24], globe: [5, 24] },
|
||||
{ id: 'bathymetry-borders-major', mercator: [3, 24], globe: [3, 24] },
|
||||
];
|
||||
|
||||
export function injectOceanBathymetryLayers(style: StyleSpecification, maptilerKey: string) {
|
||||
const oceanSourceId = 'maptiler-ocean';
|
||||
|
||||
if (!style.sources) style.sources = {} as StyleSpecification['sources'];
|
||||
if (!style.layers) style.layers = [];
|
||||
|
||||
if (!style.sources[oceanSourceId]) {
|
||||
style.sources[oceanSourceId] = {
|
||||
type: 'vector',
|
||||
url: `https://api.maptiler.com/tiles/ocean/tiles.json?key=${encodeURIComponent(maptilerKey)}`,
|
||||
} satisfies VectorSourceSpecification as unknown as StyleSpecification['sources'][string];
|
||||
}
|
||||
|
||||
const depth = ['to-number', ['get', 'depth']] as unknown as number[];
|
||||
const depthLabel = ['concat', ['to-string', ['*', depth, -1]], 'm'] as unknown as string[];
|
||||
|
||||
// Bug #3 fix: shallow depths now use brighter teal tones to distinguish from deep ocean
|
||||
const bathyFillColor = [
|
||||
'interpolate',
|
||||
['linear'],
|
||||
depth,
|
||||
-11000,
|
||||
'#00040b',
|
||||
-8000,
|
||||
'#010610',
|
||||
-6000,
|
||||
'#020816',
|
||||
-4000,
|
||||
'#030c1c',
|
||||
-2000,
|
||||
'#041022',
|
||||
-1000,
|
||||
'#051529',
|
||||
-500,
|
||||
'#061a30',
|
||||
-200,
|
||||
'#071f36',
|
||||
-100,
|
||||
'#08263d',
|
||||
-50,
|
||||
'#0e3d5e',
|
||||
-20,
|
||||
'#145578',
|
||||
-10,
|
||||
'#1a6e8e',
|
||||
0,
|
||||
'#2097a6',
|
||||
] as const;
|
||||
|
||||
const bathyFill: LayerSpecification = {
|
||||
id: 'bathymetry-fill',
|
||||
type: 'fill',
|
||||
source: oceanSourceId,
|
||||
'source-layer': 'contour',
|
||||
minzoom: 3,
|
||||
maxzoom: 24,
|
||||
paint: {
|
||||
'fill-color': bathyFillColor,
|
||||
'fill-opacity': ['interpolate', ['linear'], ['zoom'], 0, 0.9, 5, 0.88, 8, 0.84, 10, 0.78],
|
||||
},
|
||||
} as unknown as LayerSpecification;
|
||||
|
||||
const bathyBandBorders: LayerSpecification = {
|
||||
id: 'bathymetry-borders',
|
||||
type: 'line',
|
||||
source: oceanSourceId,
|
||||
'source-layer': 'contour',
|
||||
minzoom: 5, // fill은 3부터, borders는 5부터
|
||||
maxzoom: 24,
|
||||
paint: {
|
||||
'line-color': 'rgba(255,255,255,0.06)',
|
||||
'line-opacity': ['interpolate', ['linear'], ['zoom'], 4, 0.12, 8, 0.18, 12, 0.22],
|
||||
'line-blur': ['interpolate', ['linear'], ['zoom'], 4, 0.8, 10, 0.2],
|
||||
'line-width': ['interpolate', ['linear'], ['zoom'], 4, 0.2, 8, 0.35, 12, 0.6],
|
||||
},
|
||||
} as unknown as LayerSpecification;
|
||||
|
||||
const bathyLinesMinor: LayerSpecification = {
|
||||
id: 'bathymetry-lines',
|
||||
type: 'line',
|
||||
source: oceanSourceId,
|
||||
'source-layer': 'contour_line',
|
||||
minzoom: 7,
|
||||
paint: {
|
||||
'line-color': [
|
||||
'interpolate',
|
||||
['linear'],
|
||||
depth,
|
||||
-11000,
|
||||
'rgba(255,255,255,0.04)',
|
||||
-6000,
|
||||
'rgba(255,255,255,0.05)',
|
||||
-2000,
|
||||
'rgba(255,255,255,0.07)',
|
||||
0,
|
||||
'rgba(255,255,255,0.10)',
|
||||
],
|
||||
'line-opacity': ['interpolate', ['linear'], ['zoom'], 8, 0.18, 10, 0.22, 12, 0.28],
|
||||
'line-blur': ['interpolate', ['linear'], ['zoom'], 8, 0.8, 11, 0.3],
|
||||
'line-width': ['interpolate', ['linear'], ['zoom'], 8, 0.35, 10, 0.55, 12, 0.85],
|
||||
},
|
||||
} as unknown as LayerSpecification;
|
||||
|
||||
const majorDepths = [-50, -100, -200, -500, -1000, -2000, -4000, -6000, -8000, -9500];
|
||||
const bathyMajorDepthFilter: unknown[] = [
|
||||
'in',
|
||||
['to-number', ['get', 'depth']],
|
||||
['literal', majorDepths],
|
||||
] as unknown[];
|
||||
|
||||
const bathyLinesMajor: LayerSpecification = {
|
||||
id: 'bathymetry-lines-major',
|
||||
type: 'line',
|
||||
source: oceanSourceId,
|
||||
'source-layer': 'contour_line',
|
||||
minzoom: 7,
|
||||
maxzoom: 24,
|
||||
filter: bathyMajorDepthFilter as unknown as unknown[],
|
||||
paint: {
|
||||
'line-color': 'rgba(255,255,255,0.16)',
|
||||
'line-opacity': ['interpolate', ['linear'], ['zoom'], 8, 0.22, 10, 0.28, 12, 0.34],
|
||||
'line-blur': ['interpolate', ['linear'], ['zoom'], 8, 0.4, 11, 0.2],
|
||||
'line-width': ['interpolate', ['linear'], ['zoom'], 8, 0.6, 10, 0.95, 12, 1.3],
|
||||
},
|
||||
} as unknown as LayerSpecification;
|
||||
|
||||
const bathyBandBordersMajor: LayerSpecification = {
|
||||
id: 'bathymetry-borders-major',
|
||||
type: 'line',
|
||||
source: oceanSourceId,
|
||||
'source-layer': 'contour',
|
||||
minzoom: 3,
|
||||
maxzoom: 24,
|
||||
filter: bathyMajorDepthFilter as unknown as unknown[],
|
||||
paint: {
|
||||
'line-color': 'rgba(255,255,255,0.14)',
|
||||
'line-opacity': ['interpolate', ['linear'], ['zoom'], 3, 0.10, 6, 0.16, 8, 0.2, 12, 0.26],
|
||||
'line-blur': ['interpolate', ['linear'], ['zoom'], 3, 0.5, 6, 0.35, 10, 0.15],
|
||||
'line-width': ['interpolate', ['linear'], ['zoom'], 3, 0.25, 6, 0.4, 8, 0.55, 12, 0.85],
|
||||
},
|
||||
} as unknown as LayerSpecification;
|
||||
|
||||
const bathyLabels: LayerSpecification = {
|
||||
id: 'bathymetry-labels',
|
||||
type: 'symbol',
|
||||
source: oceanSourceId,
|
||||
'source-layer': 'contour_line',
|
||||
minzoom: 7,
|
||||
filter: bathyMajorDepthFilter as unknown as unknown[],
|
||||
layout: {
|
||||
'symbol-placement': 'line',
|
||||
'text-field': depthLabel,
|
||||
'text-font': ['Noto Sans Regular', 'Open Sans Regular'],
|
||||
'text-size': ['interpolate', ['linear'], ['zoom'], 7, 10, 9, 12, 11, 14, 13, 16],
|
||||
'text-allow-overlap': false,
|
||||
'text-padding': 4,
|
||||
'text-rotation-alignment': 'map',
|
||||
},
|
||||
paint: {
|
||||
'text-color': 'rgba(226,232,240,0.78)',
|
||||
'text-halo-color': 'rgba(2,6,23,0.88)',
|
||||
'text-halo-width': 1.2,
|
||||
'text-halo-blur': 0.5,
|
||||
},
|
||||
} as unknown as LayerSpecification;
|
||||
|
||||
const landformLabels: LayerSpecification = {
|
||||
id: 'bathymetry-landforms',
|
||||
type: 'symbol',
|
||||
source: oceanSourceId,
|
||||
'source-layer': 'landform',
|
||||
minzoom: 6,
|
||||
filter: ['has', 'name'] as unknown as unknown[],
|
||||
layout: {
|
||||
'text-field': ['get', 'name'] as unknown as unknown[],
|
||||
'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-allow-overlap': false,
|
||||
'text-anchor': 'center',
|
||||
'text-offset': [0, 0.0],
|
||||
},
|
||||
paint: {
|
||||
'text-color': 'rgba(148,163,184,0.75)',
|
||||
'text-halo-color': 'rgba(2,6,23,0.88)',
|
||||
'text-halo-width': 1.2,
|
||||
'text-halo-blur': 0.6,
|
||||
},
|
||||
} as unknown as LayerSpecification;
|
||||
|
||||
const layers = Array.isArray(style.layers) ? (style.layers as unknown as LayerSpecification[]) : [];
|
||||
if (!Array.isArray(style.layers)) {
|
||||
style.layers = layers as unknown as StyleSpecification['layers'];
|
||||
}
|
||||
|
||||
// Brighten base-map water fills so shallow coasts, rivers & lakes blend naturally
|
||||
// with the bathymetry gradient instead of appearing as near-black voids.
|
||||
const waterRegex = /(water|sea|ocean|river|lake|coast|bay)/i;
|
||||
const SHALLOW_WATER_FILL = SHALLOW_WATER_FILL_DEFAULT;
|
||||
const SHALLOW_WATER_LINE = SHALLOW_WATER_LINE_DEFAULT;
|
||||
for (const layer of layers) {
|
||||
const id = getLayerId(layer);
|
||||
if (!id) continue;
|
||||
const spec = layer as Record<string, unknown>;
|
||||
const sourceLayer = String(spec['source-layer'] ?? '').toLowerCase();
|
||||
const layerType = String(spec.type ?? '');
|
||||
const isWater = waterRegex.test(id) || waterRegex.test(sourceLayer);
|
||||
if (!isWater) continue;
|
||||
|
||||
const paint = (spec.paint ?? {}) as Record<string, unknown>;
|
||||
if (layerType === 'fill') {
|
||||
paint['fill-color'] = SHALLOW_WATER_FILL;
|
||||
spec.paint = paint;
|
||||
} else if (layerType === 'line') {
|
||||
paint['line-color'] = SHALLOW_WATER_LINE;
|
||||
spec.paint = paint;
|
||||
}
|
||||
}
|
||||
|
||||
const symbolIndex = layers.findIndex((l) => (l as { type?: unknown } | null)?.type === 'symbol');
|
||||
const insertAt = symbolIndex >= 0 ? symbolIndex : layers.length;
|
||||
|
||||
const existingIds = new Set<string>();
|
||||
for (const layer of layers) {
|
||||
const id = getLayerId(layer);
|
||||
if (id) existingIds.add(id);
|
||||
}
|
||||
|
||||
const toInsert = [
|
||||
bathyFill,
|
||||
bathyBandBorders,
|
||||
bathyBandBordersMajor,
|
||||
bathyLinesMinor,
|
||||
bathyLinesMajor,
|
||||
bathyLabels,
|
||||
landformLabels,
|
||||
].filter((l) => !existingIds.has(l.id));
|
||||
if (toInsert.length > 0) layers.splice(insertAt, 0, ...toInsert);
|
||||
}
|
||||
|
||||
export function applyBathymetryZoomProfile(map: maplibregl.Map, baseMap: BaseMapId, projection: MapProjectionId) {
|
||||
if (!map || !map.isStyleLoaded()) return;
|
||||
if (baseMap !== 'enhanced') return;
|
||||
const isGlobe = projection === 'globe';
|
||||
|
||||
for (const range of BATHY_ZOOM_RANGES) {
|
||||
if (!map.getLayer(range.id)) continue;
|
||||
const [minzoom, maxzoom] = isGlobe ? range.globe : range.mercator;
|
||||
try {
|
||||
map.setLayoutProperty(range.id, 'visibility', 'visible');
|
||||
} catch {
|
||||
// ignore
|
||||
}
|
||||
try {
|
||||
map.setLayerZoomRange(range.id, minzoom, maxzoom);
|
||||
} catch {
|
||||
// ignore
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
function applyKoreanLabels(style: StyleSpecification) {
|
||||
if (!style.layers) return;
|
||||
const koTextField = ['coalesce', ['get', 'name:ko'], ['get', 'name']];
|
||||
for (const layer of style.layers as unknown as LayerSpecification[]) {
|
||||
if ((layer as { type?: string }).type !== 'symbol') continue;
|
||||
const layout = (layer as Record<string, unknown>).layout as
|
||||
| Record<string, unknown>
|
||||
| undefined;
|
||||
if (!layout?.['text-field']) continue;
|
||||
layout['text-field'] = koTextField;
|
||||
}
|
||||
}
|
||||
|
||||
export async function resolveInitialMapStyle(signal: AbortSignal): Promise<string | StyleSpecification> {
|
||||
const key = getMapTilerKey();
|
||||
if (!key) return '/map/styles/osm-seamark.json';
|
||||
|
||||
const baseMapId = (import.meta.env.VITE_MAPTILER_BASE_MAP_ID || 'dataviz-dark').trim();
|
||||
const styleUrl = `https://api.maptiler.com/maps/${encodeURIComponent(baseMapId)}/style.json?key=${encodeURIComponent(key)}`;
|
||||
|
||||
const res = await fetch(styleUrl, { signal, headers: { accept: 'application/json' } });
|
||||
if (!res.ok) throw new Error(`MapTiler style fetch failed: ${res.status} ${res.statusText}`);
|
||||
const json = (await res.json()) as StyleSpecification;
|
||||
applyKoreanLabels(json);
|
||||
injectOceanBathymetryLayers(json, key);
|
||||
return json;
|
||||
}
|
||||
|
||||
export async function resolveMapStyle(baseMap: BaseMapId, signal: AbortSignal): Promise<string | StyleSpecification> {
|
||||
// 레거시 베이스맵 비활성 — 향후 위성/라이트 테마 등 추가 시 재활용
|
||||
// if (baseMap === 'legacy') return '/map/styles/carto-dark.json';
|
||||
void baseMap;
|
||||
return resolveInitialMapStyle(signal);
|
||||
}
|
||||
27
apps/web/src/widgets/map3d/layers/seamark.ts
Normal file
27
apps/web/src/widgets/map3d/layers/seamark.ts
Normal file
@ -0,0 +1,27 @@
|
||||
import maplibregl, { type LayerSpecification } from 'maplibre-gl';
|
||||
|
||||
export function ensureSeamarkOverlay(map: maplibregl.Map, beforeLayerId?: string) {
|
||||
const srcId = 'seamark';
|
||||
const layerId = 'seamark';
|
||||
|
||||
if (!map.getSource(srcId)) {
|
||||
map.addSource(srcId, {
|
||||
type: 'raster',
|
||||
tiles: ['https://tiles.openseamap.org/seamark/{z}/{x}/{y}.png'],
|
||||
tileSize: 256,
|
||||
attribution: '© OpenSeaMap contributors',
|
||||
});
|
||||
}
|
||||
|
||||
if (!map.getLayer(layerId)) {
|
||||
const layer: LayerSpecification = {
|
||||
id: layerId,
|
||||
type: 'raster',
|
||||
source: srcId,
|
||||
paint: { 'raster-opacity': 0.85 },
|
||||
} as unknown as LayerSpecification;
|
||||
|
||||
const before = beforeLayerId && map.getLayer(beforeLayerId) ? beforeLayerId : undefined;
|
||||
map.addLayer(layer, before);
|
||||
}
|
||||
}
|
||||
31
apps/web/src/widgets/map3d/lib/dashifyLine.ts
Normal file
31
apps/web/src/widgets/map3d/lib/dashifyLine.ts
Normal file
@ -0,0 +1,31 @@
|
||||
import type { DashSeg } from '../types';
|
||||
|
||||
export function dashifyLine(
|
||||
from: [number, number],
|
||||
to: [number, number],
|
||||
suspicious: boolean,
|
||||
distanceNm?: number,
|
||||
fromMmsi?: number,
|
||||
toMmsi?: number,
|
||||
): DashSeg[] {
|
||||
const segs: DashSeg[] = [];
|
||||
const steps = 14;
|
||||
for (let i = 0; i < steps; i++) {
|
||||
if (i % 2 === 1) continue;
|
||||
const a0 = i / steps;
|
||||
const a1 = (i + 1) / steps;
|
||||
const lon0 = from[0] + (to[0] - from[0]) * a0;
|
||||
const lat0 = from[1] + (to[1] - from[1]) * a0;
|
||||
const lon1 = from[0] + (to[0] - from[0]) * a1;
|
||||
const lat1 = from[1] + (to[1] - from[1]) * a1;
|
||||
segs.push({
|
||||
from: [lon0, lat0],
|
||||
to: [lon1, lat1],
|
||||
suspicious,
|
||||
distanceNm,
|
||||
fromMmsi,
|
||||
toMmsi,
|
||||
});
|
||||
}
|
||||
return segs;
|
||||
}
|
||||
19
apps/web/src/widgets/map3d/lib/featureIds.ts
Normal file
19
apps/web/src/widgets/map3d/lib/featureIds.ts
Normal file
@ -0,0 +1,19 @@
|
||||
export function makeOrderedPairKey(a: number, b: number) {
|
||||
const left = Math.trunc(Math.min(a, b));
|
||||
const right = Math.trunc(Math.max(a, b));
|
||||
return `${left}-${right}`;
|
||||
}
|
||||
|
||||
export function makePairLinkFeatureId(a: number, b: number, suffix?: string) {
|
||||
const pair = makeOrderedPairKey(a, b);
|
||||
return suffix ? `pair-${pair}-${suffix}` : `pair-${pair}`;
|
||||
}
|
||||
|
||||
export function makeFcSegmentFeatureId(a: number, b: number, segmentIndex: number) {
|
||||
const pair = makeOrderedPairKey(a, b);
|
||||
return `fc-${pair}-${segmentIndex}`;
|
||||
}
|
||||
|
||||
export function makeFleetCircleFeatureId(ownerKey: string) {
|
||||
return `fleet-${ownerKey}`;
|
||||
}
|
||||
62
apps/web/src/widgets/map3d/lib/geometry.ts
Normal file
62
apps/web/src/widgets/map3d/lib/geometry.ts
Normal file
@ -0,0 +1,62 @@
|
||||
import { DEG2RAD, RAD2DEG, EARTH_RADIUS_M } from '../constants';
|
||||
|
||||
export const clampNumber = (value: number, minValue: number, maxValue: number) =>
|
||||
Math.max(minValue, Math.min(maxValue, value));
|
||||
|
||||
export function wrapLonDeg(lon: number) {
|
||||
const v = ((lon + 180) % 360 + 360) % 360;
|
||||
return v - 180;
|
||||
}
|
||||
|
||||
export function destinationPointLngLat(
|
||||
from: [number, number],
|
||||
bearingDeg: number,
|
||||
distanceMeters: number,
|
||||
): [number, number] {
|
||||
const [lonDeg, latDeg] = from;
|
||||
const lat1 = latDeg * DEG2RAD;
|
||||
const lon1 = lonDeg * DEG2RAD;
|
||||
const brng = bearingDeg * DEG2RAD;
|
||||
const dr = Math.max(0, distanceMeters) / EARTH_RADIUS_M;
|
||||
if (!Number.isFinite(dr) || dr === 0) return [lonDeg, latDeg];
|
||||
|
||||
const sinLat1 = Math.sin(lat1);
|
||||
const cosLat1 = Math.cos(lat1);
|
||||
const sinDr = Math.sin(dr);
|
||||
const cosDr = Math.cos(dr);
|
||||
|
||||
const lat2 = Math.asin(sinLat1 * cosDr + cosLat1 * sinDr * Math.cos(brng));
|
||||
const lon2 =
|
||||
lon1 +
|
||||
Math.atan2(
|
||||
Math.sin(brng) * sinDr * cosLat1,
|
||||
cosDr - sinLat1 * Math.sin(lat2),
|
||||
);
|
||||
|
||||
const outLon = wrapLonDeg(lon2 * RAD2DEG);
|
||||
const outLat = clampNumber(lat2 * RAD2DEG, -85.0, 85.0);
|
||||
return [outLon, outLat];
|
||||
}
|
||||
|
||||
export function circleRingLngLat(center: [number, number], radiusMeters: number, steps = 72): [number, number][] {
|
||||
const [lon0, lat0] = center;
|
||||
const latRad = lat0 * DEG2RAD;
|
||||
const cosLat = Math.max(1e-6, Math.cos(latRad));
|
||||
const r = Math.max(0, radiusMeters);
|
||||
|
||||
const ring: [number, number][] = [];
|
||||
for (let i = 0; i <= steps; i++) {
|
||||
const a = (i / steps) * Math.PI * 2;
|
||||
const dy = r * Math.sin(a);
|
||||
const dx = r * Math.cos(a);
|
||||
const dLat = (dy / EARTH_RADIUS_M) / DEG2RAD;
|
||||
const dLon = (dx / (EARTH_RADIUS_M * cosLat)) / DEG2RAD;
|
||||
ring.push([lon0 + dLon, lat0 + dLat]);
|
||||
}
|
||||
return ring;
|
||||
}
|
||||
|
||||
export function normalizeAngleDeg(value: number, offset = 0): number {
|
||||
const v = value + offset;
|
||||
return ((v % 360) + 360) % 360;
|
||||
}
|
||||
76
apps/web/src/widgets/map3d/lib/globeShipIcon.ts
Normal file
76
apps/web/src/widgets/map3d/lib/globeShipIcon.ts
Normal file
@ -0,0 +1,76 @@
|
||||
import maplibregl from 'maplibre-gl';
|
||||
|
||||
export function buildFallbackGlobeShipIcon() {
|
||||
const size = 96;
|
||||
const canvas = document.createElement('canvas');
|
||||
canvas.width = size;
|
||||
canvas.height = size;
|
||||
const ctx = canvas.getContext('2d');
|
||||
if (!ctx) return null;
|
||||
|
||||
ctx.clearRect(0, 0, size, size);
|
||||
ctx.fillStyle = 'rgba(255,255,255,1)';
|
||||
ctx.beginPath();
|
||||
ctx.moveTo(size / 2, 6);
|
||||
ctx.lineTo(size / 2 - 14, 24);
|
||||
ctx.lineTo(size / 2 - 18, 58);
|
||||
ctx.lineTo(size / 2 - 10, 88);
|
||||
ctx.lineTo(size / 2 + 10, 88);
|
||||
ctx.lineTo(size / 2 + 18, 58);
|
||||
ctx.lineTo(size / 2 + 14, 24);
|
||||
ctx.closePath();
|
||||
ctx.fill();
|
||||
|
||||
ctx.fillRect(size / 2 - 8, 34, 16, 18);
|
||||
|
||||
return ctx.getImageData(0, 0, size, size);
|
||||
}
|
||||
|
||||
export function buildFallbackGlobeAnchoredShipIcon() {
|
||||
const baseImage = buildFallbackGlobeShipIcon();
|
||||
if (!baseImage) return null;
|
||||
|
||||
const size = baseImage.width;
|
||||
const canvas = document.createElement('canvas');
|
||||
canvas.width = size;
|
||||
canvas.height = size;
|
||||
const ctx = canvas.getContext('2d');
|
||||
if (!ctx) return null;
|
||||
|
||||
ctx.putImageData(baseImage, 0, 0);
|
||||
|
||||
ctx.strokeStyle = 'rgba(248,250,252,1)';
|
||||
ctx.lineWidth = 5;
|
||||
ctx.lineCap = 'round';
|
||||
ctx.beginPath();
|
||||
const cx = size / 2;
|
||||
ctx.moveTo(cx - 18, 76);
|
||||
ctx.lineTo(cx + 18, 76);
|
||||
ctx.moveTo(cx, 66);
|
||||
ctx.lineTo(cx, 82);
|
||||
ctx.moveTo(cx, 82);
|
||||
ctx.arc(cx, 82, 7, 0, Math.PI * 2);
|
||||
ctx.moveTo(cx, 82);
|
||||
ctx.lineTo(cx, 88);
|
||||
ctx.moveTo(cx - 9, 88);
|
||||
ctx.lineTo(cx + 9, 88);
|
||||
ctx.stroke();
|
||||
|
||||
return ctx.getImageData(0, 0, size, size);
|
||||
}
|
||||
|
||||
export function ensureFallbackShipImage(
|
||||
map: maplibregl.Map,
|
||||
imageId: string,
|
||||
fallbackBuilder: () => ImageData | null = buildFallbackGlobeShipIcon,
|
||||
) {
|
||||
if (!map || map.hasImage(imageId)) return;
|
||||
const image = fallbackBuilder();
|
||||
if (!image) return;
|
||||
|
||||
try {
|
||||
map.addImage(imageId, image, { pixelRatio: 2, sdf: true });
|
||||
} catch {
|
||||
// ignore
|
||||
}
|
||||
}
|
||||
128
apps/web/src/widgets/map3d/lib/layerHelpers.ts
Normal file
128
apps/web/src/widgets/map3d/lib/layerHelpers.ts
Normal file
@ -0,0 +1,128 @@
|
||||
import maplibregl, {
|
||||
type GeoJSONSourceSpecification,
|
||||
type LayerSpecification,
|
||||
} from 'maplibre-gl';
|
||||
|
||||
export function removeLayerIfExists(map: maplibregl.Map, layerId: string | null | undefined) {
|
||||
if (!layerId) return;
|
||||
try {
|
||||
if (map.getLayer(layerId)) {
|
||||
map.removeLayer(layerId);
|
||||
}
|
||||
} catch {
|
||||
// ignore
|
||||
}
|
||||
}
|
||||
|
||||
export function removeSourceIfExists(map: maplibregl.Map, sourceId: string) {
|
||||
try {
|
||||
if (map.getSource(sourceId)) {
|
||||
map.removeSource(sourceId);
|
||||
}
|
||||
} catch {
|
||||
// ignore
|
||||
}
|
||||
}
|
||||
|
||||
const GLOBE_NATIVE_LAYER_IDS = [
|
||||
'ships-globe-halo',
|
||||
'ships-globe-outline',
|
||||
'ships-globe',
|
||||
'ships-globe-label',
|
||||
'ships-globe-hover-halo',
|
||||
'ships-globe-hover-outline',
|
||||
'ships-globe-hover',
|
||||
'pair-lines-ml',
|
||||
'fc-lines-ml',
|
||||
'fleet-circles-ml-fill',
|
||||
'fleet-circles-ml',
|
||||
'pair-range-ml',
|
||||
'subcables-hitarea',
|
||||
'subcables-casing',
|
||||
'subcables-line',
|
||||
'subcables-glow',
|
||||
'subcables-points',
|
||||
'subcables-label',
|
||||
'deck-globe',
|
||||
];
|
||||
|
||||
const GLOBE_NATIVE_SOURCE_IDS = [
|
||||
'ships-globe-src',
|
||||
'ships-globe-hover-src',
|
||||
'pair-lines-ml-src',
|
||||
'fc-lines-ml-src',
|
||||
'fleet-circles-ml-src',
|
||||
'fleet-circles-ml-fill-src',
|
||||
'pair-range-ml-src',
|
||||
'subcables-src',
|
||||
'subcables-pts-src',
|
||||
];
|
||||
|
||||
export function clearGlobeNativeLayers(map: maplibregl.Map) {
|
||||
for (const id of GLOBE_NATIVE_LAYER_IDS) {
|
||||
removeLayerIfExists(map, id);
|
||||
}
|
||||
for (const id of GLOBE_NATIVE_SOURCE_IDS) {
|
||||
removeSourceIfExists(map, id);
|
||||
}
|
||||
}
|
||||
|
||||
export function ensureGeoJsonSource(
|
||||
map: maplibregl.Map,
|
||||
sourceId: string,
|
||||
data: GeoJSON.GeoJSON,
|
||||
options?: Partial<Omit<GeoJSONSourceSpecification, 'type' | 'data'>>,
|
||||
) {
|
||||
const existing = map.getSource(sourceId);
|
||||
if (existing) {
|
||||
(existing as maplibregl.GeoJSONSource).setData(data);
|
||||
} else {
|
||||
map.addSource(sourceId, {
|
||||
type: 'geojson',
|
||||
data,
|
||||
...options,
|
||||
} satisfies GeoJSONSourceSpecification);
|
||||
}
|
||||
}
|
||||
|
||||
export function ensureLayer(
|
||||
map: maplibregl.Map,
|
||||
spec: LayerSpecification,
|
||||
options?: { before?: string },
|
||||
) {
|
||||
if (map.getLayer(spec.id)) return;
|
||||
const before = options?.before && map.getLayer(options.before) ? options.before : undefined;
|
||||
map.addLayer(spec, before);
|
||||
}
|
||||
|
||||
export function setLayerVisibility(map: maplibregl.Map, layerId: string, visible: boolean) {
|
||||
if (!map.getLayer(layerId)) return;
|
||||
try {
|
||||
map.setLayoutProperty(layerId, 'visibility', visible ? 'visible' : 'none');
|
||||
} catch {
|
||||
// ignore
|
||||
}
|
||||
}
|
||||
|
||||
export function cleanupLayers(
|
||||
map: maplibregl.Map,
|
||||
layerIds: string[],
|
||||
sourceIds: string[],
|
||||
) {
|
||||
requestAnimationFrame(() => {
|
||||
for (const id of layerIds) {
|
||||
try {
|
||||
if (map.getLayer(id)) map.removeLayer(id);
|
||||
} catch {
|
||||
// ignore
|
||||
}
|
||||
}
|
||||
for (const id of sourceIds) {
|
||||
try {
|
||||
if (map.getSource(id)) map.removeSource(id);
|
||||
} catch {
|
||||
// ignore
|
||||
}
|
||||
}
|
||||
});
|
||||
}
|
||||
124
apps/web/src/widgets/map3d/lib/mapCore.ts
Normal file
124
apps/web/src/widgets/map3d/lib/mapCore.ts
Normal file
@ -0,0 +1,124 @@
|
||||
import maplibregl from 'maplibre-gl';
|
||||
import type { MapProjectionId } from '../types';
|
||||
|
||||
export function kickRepaint(map: maplibregl.Map | null) {
|
||||
if (!map) return;
|
||||
try {
|
||||
if (map.isStyleLoaded()) map.triggerRepaint();
|
||||
} catch {
|
||||
// ignore
|
||||
}
|
||||
try {
|
||||
requestAnimationFrame(() => {
|
||||
try {
|
||||
if (map.isStyleLoaded()) map.triggerRepaint();
|
||||
} catch {
|
||||
// ignore
|
||||
}
|
||||
});
|
||||
requestAnimationFrame(() => {
|
||||
try {
|
||||
if (map.isStyleLoaded()) map.triggerRepaint();
|
||||
} catch {
|
||||
// ignore
|
||||
}
|
||||
});
|
||||
} catch {
|
||||
// ignore (e.g., non-browser env)
|
||||
}
|
||||
}
|
||||
|
||||
export function onMapStyleReady(map: maplibregl.Map | null, callback: () => void) {
|
||||
if (!map) {
|
||||
return () => {
|
||||
// noop
|
||||
};
|
||||
}
|
||||
if (map.isStyleLoaded()) {
|
||||
callback();
|
||||
return () => {
|
||||
// noop
|
||||
};
|
||||
}
|
||||
|
||||
let fired = false;
|
||||
const runOnce = () => {
|
||||
if (!map || fired || !map.isStyleLoaded()) return;
|
||||
fired = true;
|
||||
callback();
|
||||
try {
|
||||
map.off('style.load', runOnce);
|
||||
map.off('styledata', runOnce);
|
||||
map.off('idle', runOnce);
|
||||
} catch {
|
||||
// ignore
|
||||
}
|
||||
};
|
||||
|
||||
map.on('style.load', runOnce);
|
||||
map.on('styledata', runOnce);
|
||||
map.on('idle', runOnce);
|
||||
|
||||
return () => {
|
||||
if (fired) return;
|
||||
fired = true;
|
||||
try {
|
||||
if (!map) return;
|
||||
map.off('style.load', runOnce);
|
||||
map.off('styledata', runOnce);
|
||||
map.off('idle', runOnce);
|
||||
} catch {
|
||||
// ignore
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
export function extractProjectionType(map: maplibregl.Map): MapProjectionId | undefined {
|
||||
const projection = map.getProjection?.();
|
||||
if (!projection || typeof projection !== 'object') return undefined;
|
||||
|
||||
const rawType = (projection as { type?: unknown; name?: unknown }).type ?? (projection as { type?: unknown; name?: unknown }).name;
|
||||
if (rawType === 'globe') return 'globe';
|
||||
if (rawType === 'mercator') return 'mercator';
|
||||
return undefined;
|
||||
}
|
||||
|
||||
export function getMapTilerKey(): string | null {
|
||||
const k = import.meta.env.VITE_MAPTILER_KEY;
|
||||
if (typeof k !== 'string') return null;
|
||||
const v = k.trim();
|
||||
return v ? v : null;
|
||||
}
|
||||
|
||||
export function getLayerId(value: unknown): string | null {
|
||||
if (!value || typeof value !== 'object') return null;
|
||||
const candidate = (value as { id?: unknown }).id;
|
||||
return typeof candidate === 'string' ? candidate : null;
|
||||
}
|
||||
|
||||
export function sanitizeDeckLayerList(value: unknown): unknown[] {
|
||||
if (!Array.isArray(value)) return [];
|
||||
const seen = new Set<string>();
|
||||
const out: unknown[] = [];
|
||||
let dropped = 0;
|
||||
|
||||
for (const layer of value) {
|
||||
const layerId = getLayerId(layer);
|
||||
if (!layerId) {
|
||||
dropped += 1;
|
||||
continue;
|
||||
}
|
||||
if (seen.has(layerId)) {
|
||||
dropped += 1;
|
||||
continue;
|
||||
}
|
||||
seen.add(layerId);
|
||||
out.push(layer);
|
||||
}
|
||||
|
||||
if (dropped > 0 && import.meta.env.DEV) {
|
||||
console.warn(`Sanitized deck layer list: dropped ${dropped} invalid/duplicate entries`);
|
||||
}
|
||||
|
||||
return out;
|
||||
}
|
||||
68
apps/web/src/widgets/map3d/lib/mlExpressions.ts
Normal file
68
apps/web/src/widgets/map3d/lib/mlExpressions.ts
Normal file
@ -0,0 +1,68 @@
|
||||
export function makeMmsiPairHighlightExpr(aField: string, bField: string, hoveredMmsiList: number[]) {
|
||||
if (!Array.isArray(hoveredMmsiList) || hoveredMmsiList.length < 2) {
|
||||
return false;
|
||||
}
|
||||
const inA = ['in', ['to-number', ['get', aField]], ['literal', hoveredMmsiList]] as unknown[];
|
||||
const inB = ['in', ['to-number', ['get', bField]], ['literal', hoveredMmsiList]] as unknown[];
|
||||
return ['all', inA, inB] as unknown[];
|
||||
}
|
||||
|
||||
export function makeMmsiAnyEndpointExpr(aField: string, bField: string, hoveredMmsiList: number[]) {
|
||||
if (!Array.isArray(hoveredMmsiList) || hoveredMmsiList.length === 0) {
|
||||
return false;
|
||||
}
|
||||
const literal = ['literal', hoveredMmsiList] as unknown[];
|
||||
return [
|
||||
'any',
|
||||
['in', ['to-number', ['get', aField]], literal],
|
||||
['in', ['to-number', ['get', bField]], literal],
|
||||
] as unknown[];
|
||||
}
|
||||
|
||||
export function makeFleetOwnerMatchExpr(hoveredOwnerKeys: string[]) {
|
||||
if (!Array.isArray(hoveredOwnerKeys) || hoveredOwnerKeys.length === 0) {
|
||||
return false;
|
||||
}
|
||||
const expr = ['match', ['to-string', ['coalesce', ['get', 'ownerKey'], '']]] as unknown[];
|
||||
for (const ownerKey of hoveredOwnerKeys) {
|
||||
expr.push(String(ownerKey), true);
|
||||
}
|
||||
expr.push(false);
|
||||
return expr;
|
||||
}
|
||||
|
||||
export function makeFleetMemberMatchExpr(hoveredFleetMmsiList: number[]) {
|
||||
if (!Array.isArray(hoveredFleetMmsiList) || hoveredFleetMmsiList.length === 0) {
|
||||
return false;
|
||||
}
|
||||
const clauses = hoveredFleetMmsiList.map((mmsi) =>
|
||||
['in', mmsi, ['coalesce', ['get', 'vesselMmsis'], ['literal', []]]] as unknown[],
|
||||
);
|
||||
return ['any', ...clauses] as unknown[];
|
||||
}
|
||||
|
||||
export function makeGlobeCircleRadiusExpr() {
|
||||
const base3 = 4;
|
||||
const base7 = 6;
|
||||
const base10 = 8;
|
||||
const base14 = 12;
|
||||
const base18 = 32;
|
||||
|
||||
return [
|
||||
'interpolate',
|
||||
['linear'],
|
||||
['zoom'],
|
||||
3,
|
||||
['case', ['==', ['get', 'selected'], 1], 4.6, ['==', ['get', 'highlighted'], 1], 4.2, base3],
|
||||
7,
|
||||
['case', ['==', ['get', 'selected'], 1], 6.8, ['==', ['get', 'highlighted'], 1], 6.2, base7],
|
||||
10,
|
||||
['case', ['==', ['get', 'selected'], 1], 9.0, ['==', ['get', 'highlighted'], 1], 8.2, base10],
|
||||
14,
|
||||
['case', ['==', ['get', 'selected'], 1], 13.5, ['==', ['get', 'highlighted'], 1], 12.6, base14],
|
||||
18,
|
||||
['case', ['==', ['get', 'selected'], 1], 36, ['==', ['get', 'highlighted'], 1], 34, base18],
|
||||
];
|
||||
}
|
||||
|
||||
export const GLOBE_SHIP_CIRCLE_RADIUS_EXPR = makeGlobeCircleRadiusExpr() as never;
|
||||
79
apps/web/src/widgets/map3d/lib/setUtils.ts
Normal file
79
apps/web/src/widgets/map3d/lib/setUtils.ts
Normal file
@ -0,0 +1,79 @@
|
||||
export function toNumberSet(values: number[] | undefined | null) {
|
||||
const out = new Set<number>();
|
||||
if (!values) return out;
|
||||
for (const value of values) {
|
||||
if (Number.isFinite(value)) {
|
||||
out.add(value);
|
||||
}
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
export function mergeNumberSets(...sets: Set<number>[]) {
|
||||
const out = new Set<number>();
|
||||
for (const s of sets) {
|
||||
for (const v of s) {
|
||||
out.add(v);
|
||||
}
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
export function makeSetSignature(values: Set<number>) {
|
||||
return Array.from(values).sort((a, b) => a - b).join(',');
|
||||
}
|
||||
|
||||
export function toSafeNumber(value: unknown): number | null {
|
||||
if (typeof value === 'number' && Number.isFinite(value)) return value;
|
||||
return null;
|
||||
}
|
||||
|
||||
export function toIntMmsi(value: unknown): number | null {
|
||||
const n = toSafeNumber(value);
|
||||
if (n == null) return null;
|
||||
return Math.trunc(n);
|
||||
}
|
||||
|
||||
export function isFiniteNumber(x: unknown): x is number {
|
||||
return typeof x === 'number' && Number.isFinite(x);
|
||||
}
|
||||
|
||||
export const toNumberArray = (values: unknown): number[] => {
|
||||
if (values == null) return [];
|
||||
if (Array.isArray(values)) {
|
||||
return values as unknown as number[];
|
||||
}
|
||||
if (typeof values === 'number' && Number.isFinite(values)) {
|
||||
return [values];
|
||||
}
|
||||
if (typeof values === 'string') {
|
||||
const value = toSafeNumber(Number(values));
|
||||
return value == null ? [] : [value];
|
||||
}
|
||||
if (typeof values === 'object') {
|
||||
if (typeof (values as { [Symbol.iterator]?: unknown })?.[Symbol.iterator] === 'function') {
|
||||
try {
|
||||
return Array.from(values as Iterable<unknown>) as number[];
|
||||
} catch {
|
||||
return [];
|
||||
}
|
||||
}
|
||||
}
|
||||
return [];
|
||||
};
|
||||
|
||||
export const makeUniqueSorted = (values: unknown) => {
|
||||
const maybeArray = toNumberArray(values);
|
||||
const normalized = Array.isArray(maybeArray) ? maybeArray : [];
|
||||
const unique = Array.from(new Set(normalized.filter((value) => Number.isFinite(value))));
|
||||
unique.sort((a, b) => a - b);
|
||||
return unique;
|
||||
};
|
||||
|
||||
export const equalNumberArrays = (a: number[], b: number[]) => {
|
||||
if (a.length !== b.length) return false;
|
||||
for (let i = 0; i < a.length; i += 1) {
|
||||
if (a[i] !== b[i]) return false;
|
||||
}
|
||||
return true;
|
||||
};
|
||||
117
apps/web/src/widgets/map3d/lib/shipUtils.ts
Normal file
117
apps/web/src/widgets/map3d/lib/shipUtils.ts
Normal file
@ -0,0 +1,117 @@
|
||||
import type { AisTarget } from '../../../entities/aisTarget/model/types';
|
||||
import type { LegacyVesselInfo } from '../../../entities/legacyVessel/model/types';
|
||||
import { rgbToHex } from '../../../shared/lib/map/palette';
|
||||
import {
|
||||
ANCHOR_SPEED_THRESHOLD_KN,
|
||||
LEGACY_CODE_COLORS,
|
||||
MAP_SELECTED_SHIP_RGB,
|
||||
MAP_HIGHLIGHT_SHIP_RGB,
|
||||
MAP_DEFAULT_SHIP_RGB,
|
||||
} from '../constants';
|
||||
import { isFiniteNumber } from './setUtils';
|
||||
import { normalizeAngleDeg } from './geometry';
|
||||
|
||||
export function toValidBearingDeg(value: unknown): number | null {
|
||||
if (typeof value !== 'number' || !Number.isFinite(value)) return null;
|
||||
if (value === 511) return null;
|
||||
if (value < 0) return null;
|
||||
if (value >= 360) return null;
|
||||
return value;
|
||||
}
|
||||
|
||||
export function isAnchoredShip({
|
||||
sog,
|
||||
cog,
|
||||
heading,
|
||||
}: {
|
||||
sog: number | null | undefined;
|
||||
cog: number | null | undefined;
|
||||
heading: number | null | undefined;
|
||||
}): boolean {
|
||||
if (!isFiniteNumber(sog)) return true;
|
||||
if (sog <= ANCHOR_SPEED_THRESHOLD_KN) return true;
|
||||
return toValidBearingDeg(cog) == null && toValidBearingDeg(heading) == null;
|
||||
}
|
||||
|
||||
export function getDisplayHeading({
|
||||
cog,
|
||||
heading,
|
||||
offset = 0,
|
||||
}: {
|
||||
cog: number | null | undefined;
|
||||
heading: number | null | undefined;
|
||||
offset?: number;
|
||||
}) {
|
||||
const raw = toValidBearingDeg(cog) ?? toValidBearingDeg(heading) ?? 0;
|
||||
return normalizeAngleDeg(raw, offset);
|
||||
}
|
||||
|
||||
export function lightenColor(rgb: [number, number, number], ratio = 0.32) {
|
||||
const out = rgb.map((v) => Math.round(v + (255 - v) * ratio) as number) as [number, number, number];
|
||||
return out;
|
||||
}
|
||||
|
||||
export function getGlobeBaseShipColor({
|
||||
legacy,
|
||||
sog,
|
||||
}: {
|
||||
legacy: string | null;
|
||||
sog: number | null;
|
||||
}) {
|
||||
if (legacy) {
|
||||
const rgb = LEGACY_CODE_COLORS[legacy];
|
||||
if (rgb) return rgbToHex(lightenColor(rgb, 0.38));
|
||||
}
|
||||
|
||||
if (!isFiniteNumber(sog)) return 'rgba(100,116,139,0.55)';
|
||||
if (sog >= 10) return 'rgba(148,163,184,0.78)';
|
||||
if (sog >= 1) return 'rgba(100,116,139,0.74)';
|
||||
return 'rgba(71,85,105,0.68)';
|
||||
}
|
||||
|
||||
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], 130];
|
||||
if (t.sog >= 10) return [148, 163, 184, 185];
|
||||
if (t.sog >= 1) return [MAP_DEFAULT_SHIP_RGB[0], MAP_DEFAULT_SHIP_RGB[1], MAP_DEFAULT_SHIP_RGB[2], 175];
|
||||
return [71, 85, 105, 165];
|
||||
}
|
||||
|
||||
export function buildGlobeShipFeature(
|
||||
t: AisTarget,
|
||||
legacy: LegacyVesselInfo | undefined,
|
||||
selectedMmsi: number | null,
|
||||
highlightedMmsis: Set<number>,
|
||||
offset: number,
|
||||
) {
|
||||
const isSelected = selectedMmsi != null && t.mmsi === selectedMmsi ? 1 : 0;
|
||||
const isHighlighted = highlightedMmsis.has(t.mmsi) ? 1 : 0;
|
||||
const anchored = isAnchoredShip(t);
|
||||
|
||||
return {
|
||||
mmsi: t.mmsi,
|
||||
heading: getDisplayHeading({ cog: t.cog, heading: t.heading, offset }),
|
||||
anchored,
|
||||
color: getGlobeBaseShipColor({ legacy: legacy?.shipCode ?? null, sog: t.sog ?? null }),
|
||||
selected: isSelected,
|
||||
highlighted: isHighlighted,
|
||||
permitted: legacy ? 1 : 0,
|
||||
labelName: (t.name || '').trim() || legacy?.shipNameCn || legacy?.shipNameRoman || '',
|
||||
legacyTag: legacy ? `${legacy.permitNo} (${legacy.shipCode})` : '',
|
||||
};
|
||||
}
|
||||
169
apps/web/src/widgets/map3d/lib/tooltips.ts
Normal file
169
apps/web/src/widgets/map3d/lib/tooltips.ts
Normal file
@ -0,0 +1,169 @@
|
||||
import type { AisTarget } from '../../../entities/aisTarget/model/types';
|
||||
import type { LegacyVesselInfo } from '../../../entities/legacyVessel/model/types';
|
||||
import { isFiniteNumber, toSafeNumber } from './setUtils';
|
||||
|
||||
export function formatNm(value: number | null | undefined) {
|
||||
if (!isFiniteNumber(value)) return '-';
|
||||
return `${value.toFixed(2)} NM`;
|
||||
}
|
||||
|
||||
export function getLegacyTag(legacyHits: Map<number, LegacyVesselInfo> | null | undefined, mmsi: number) {
|
||||
const legacy = legacyHits?.get(mmsi);
|
||||
if (!legacy) return null;
|
||||
return `${legacy.permitNo} (${legacy.shipCode})`;
|
||||
}
|
||||
|
||||
export function getTargetName(
|
||||
mmsi: number,
|
||||
targetByMmsi: Map<number, AisTarget>,
|
||||
legacyHits: Map<number, LegacyVesselInfo> | null | undefined,
|
||||
) {
|
||||
const legacy = legacyHits?.get(mmsi);
|
||||
const target = targetByMmsi.get(mmsi);
|
||||
return (
|
||||
(target?.name || '').trim() || legacy?.shipNameCn || legacy?.shipNameRoman || `MMSI ${mmsi}`
|
||||
);
|
||||
}
|
||||
|
||||
export function getShipTooltipHtml({
|
||||
mmsi,
|
||||
targetByMmsi,
|
||||
legacyHits,
|
||||
}: {
|
||||
mmsi: number;
|
||||
targetByMmsi: Map<number, AisTarget>;
|
||||
legacyHits: Map<number, LegacyVesselInfo> | null | undefined;
|
||||
}) {
|
||||
const legacy = legacyHits?.get(mmsi);
|
||||
const t = targetByMmsi.get(mmsi);
|
||||
const name = getTargetName(mmsi, targetByMmsi, legacyHits);
|
||||
const sog = isFiniteNumber(t?.sog) ? t.sog : null;
|
||||
const cog = isFiniteNumber(t?.cog) ? t.cog : null;
|
||||
const msg = t?.messageTimestamp ?? null;
|
||||
const vesselType = t?.vesselType || '';
|
||||
|
||||
const legacyHtml = legacy
|
||||
? `<div style="margin-top: 6px; padding-top: 6px; border-top: 1px solid rgba(255,255,255,.08)">
|
||||
<div><b>CN Permit</b> · <b>${legacy.shipCode}</b> · ${legacy.permitNo}</div>
|
||||
<div>유효범위: ${legacy.workSeaArea || '-'}</div>
|
||||
</div>`
|
||||
: '';
|
||||
|
||||
return {
|
||||
html: `<div style="font-family: system-ui; font-size: 12px; white-space: nowrap;">
|
||||
<div style="font-weight: 700; margin-bottom: 4px;">${name}</div>
|
||||
<div>MMSI: <b>${mmsi}</b>${vesselType ? ` · ${vesselType}` : ''}</div>
|
||||
<div>SOG: <b>${sog ?? '?'}</b> kt · COG: <b>${cog ?? '?'}</b>°</div>
|
||||
${msg ? `<div style="opacity:.7; font-size: 11px; margin-top: 4px;">${msg}</div>` : ''}
|
||||
${legacyHtml}
|
||||
</div>`,
|
||||
};
|
||||
}
|
||||
|
||||
export function getPairLinkTooltipHtml({
|
||||
warn,
|
||||
distanceNm,
|
||||
aMmsi,
|
||||
bMmsi,
|
||||
legacyHits,
|
||||
targetByMmsi,
|
||||
}: {
|
||||
warn: boolean;
|
||||
distanceNm: number | null | undefined;
|
||||
aMmsi: number;
|
||||
bMmsi: number;
|
||||
legacyHits: Map<number, LegacyVesselInfo> | null | undefined;
|
||||
targetByMmsi: Map<number, AisTarget>;
|
||||
}) {
|
||||
const d = formatNm(distanceNm);
|
||||
const a = getTargetName(aMmsi, targetByMmsi, legacyHits);
|
||||
const b = getTargetName(bMmsi, targetByMmsi, legacyHits);
|
||||
const aTag = getLegacyTag(legacyHits, aMmsi);
|
||||
const bTag = getLegacyTag(legacyHits, bMmsi);
|
||||
return {
|
||||
html: `<div style="font-family: system-ui; font-size: 12px;">
|
||||
<div style="font-weight: 700; margin-bottom: 4px;">쌍 연결</div>
|
||||
<div>${aTag ?? `MMSI ${aMmsi}`}</div>
|
||||
<div style="opacity:.85;">↔ ${bTag ?? `MMSI ${bMmsi}`}</div>
|
||||
<div style="margin-top: 4px;">거리: <b>${d}</b> · 상태: <b>${warn ? '주의' : '정상'}</b></div>
|
||||
<div style="opacity:.6; margin-top: 3px;">${a} / ${b}</div>
|
||||
</div>`,
|
||||
};
|
||||
}
|
||||
|
||||
export function getFcLinkTooltipHtml({
|
||||
suspicious,
|
||||
distanceNm,
|
||||
fcMmsi,
|
||||
otherMmsi,
|
||||
legacyHits,
|
||||
targetByMmsi,
|
||||
}: {
|
||||
suspicious: boolean;
|
||||
distanceNm: number | null | undefined;
|
||||
fcMmsi: number;
|
||||
otherMmsi: number;
|
||||
legacyHits: Map<number, LegacyVesselInfo> | null | undefined;
|
||||
targetByMmsi: Map<number, AisTarget>;
|
||||
}) {
|
||||
const d = formatNm(distanceNm);
|
||||
const a = getTargetName(fcMmsi, targetByMmsi, legacyHits);
|
||||
const b = getTargetName(otherMmsi, targetByMmsi, legacyHits);
|
||||
const aTag = getLegacyTag(legacyHits, fcMmsi);
|
||||
const bTag = getLegacyTag(legacyHits, otherMmsi);
|
||||
return {
|
||||
html: `<div style="font-family: system-ui; font-size: 12px;">
|
||||
<div style="font-weight: 700; margin-bottom: 4px;">환적 연결</div>
|
||||
<div>${aTag ?? `MMSI ${fcMmsi}`}</div>
|
||||
<div style="opacity:.85;">→ ${bTag ?? `MMSI ${otherMmsi}`}</div>
|
||||
<div style="margin-top: 4px;">거리: <b>${d}</b> · 상태: <b>${suspicious ? '의심' : '일반'}</b></div>
|
||||
<div style="opacity:.6; margin-top: 3px;">${a} / ${b}</div>
|
||||
</div>`,
|
||||
};
|
||||
}
|
||||
|
||||
export function getRangeTooltipHtml({
|
||||
warn,
|
||||
distanceNm,
|
||||
aMmsi,
|
||||
bMmsi,
|
||||
legacyHits,
|
||||
}: {
|
||||
warn: boolean;
|
||||
distanceNm: number | null | undefined;
|
||||
aMmsi: number;
|
||||
bMmsi: number;
|
||||
legacyHits: Map<number, LegacyVesselInfo> | null | undefined;
|
||||
}) {
|
||||
const d = formatNm(distanceNm);
|
||||
const aTag = getLegacyTag(legacyHits, aMmsi);
|
||||
const bTag = getLegacyTag(legacyHits, bMmsi);
|
||||
const radiusNm = toSafeNumber(distanceNm);
|
||||
return {
|
||||
html: `<div style="font-family: system-ui; font-size: 12px;">
|
||||
<div style="font-weight: 700; margin-bottom: 4px;">쌍 연결범위</div>
|
||||
<div>${aTag ?? `MMSI ${aMmsi}`}</div>
|
||||
<div style="opacity:.85;">↔ ${bTag ?? `MMSI ${bMmsi}`}</div>
|
||||
<div style="margin-top: 4px;">범위: <b>${d}</b> · 반경: <b>${formatNm(radiusNm == null ? null : radiusNm / 2)}</b> · 상태: <b>${warn ? '주의' : '정상'}</b></div>
|
||||
</div>`,
|
||||
};
|
||||
}
|
||||
|
||||
export function getFleetCircleTooltipHtml({
|
||||
ownerKey,
|
||||
ownerLabel,
|
||||
count,
|
||||
}: {
|
||||
ownerKey: string;
|
||||
ownerLabel?: string;
|
||||
count: number;
|
||||
}) {
|
||||
const displayOwner = ownerLabel && ownerLabel.trim() ? ownerLabel : ownerKey;
|
||||
return {
|
||||
html: `<div style="font-family: system-ui; font-size: 12px;">
|
||||
<div style="font-weight: 700; margin-bottom: 4px;">선단 범위</div>
|
||||
<div>소유주: ${displayOwner || '-'}</div>
|
||||
<div>선박 수: <b>${count}</b></div>
|
||||
</div>`,
|
||||
};
|
||||
}
|
||||
40
apps/web/src/widgets/map3d/lib/zoneUtils.ts
Normal file
40
apps/web/src/widgets/map3d/lib/zoneUtils.ts
Normal file
@ -0,0 +1,40 @@
|
||||
import type { ZoneId } from '../../../entities/zone/model/meta';
|
||||
import { ZONE_META } from '../../../entities/zone/model/meta';
|
||||
|
||||
function toTextValue(value: unknown): string {
|
||||
if (value == null) return '';
|
||||
return String(value).trim();
|
||||
}
|
||||
|
||||
export function getZoneIdFromProps(props: Record<string, unknown> | null | undefined): string {
|
||||
const safeProps = props || {};
|
||||
const candidates = [
|
||||
'zoneId',
|
||||
'zone_id',
|
||||
'zoneIdNo',
|
||||
'zoneKey',
|
||||
'zoneCode',
|
||||
'ZONE_ID',
|
||||
'ZONECODE',
|
||||
'id',
|
||||
];
|
||||
|
||||
for (const key of candidates) {
|
||||
const value = toTextValue(safeProps[key]);
|
||||
if (value) return value;
|
||||
}
|
||||
|
||||
return '';
|
||||
}
|
||||
|
||||
export function getZoneDisplayNameFromProps(props: Record<string, unknown> | null | undefined): string {
|
||||
const safeProps = props || {};
|
||||
const nameCandidates = ['zoneName', 'zoneLabel', 'NAME', 'name', 'ZONE_NM', 'label'];
|
||||
for (const key of nameCandidates) {
|
||||
const name = toTextValue(safeProps[key]);
|
||||
if (name) return name;
|
||||
}
|
||||
const zoneId = getZoneIdFromProps(safeProps);
|
||||
if (!zoneId) return '수역';
|
||||
return ZONE_META[(zoneId as ZoneId)]?.name || `수역 ${zoneId}`;
|
||||
}
|
||||
79
apps/web/src/widgets/map3d/types.ts
Normal file
79
apps/web/src/widgets/map3d/types.ts
Normal file
@ -0,0 +1,79 @@
|
||||
import type { AisTarget } from '../../entities/aisTarget/model/types';
|
||||
import type { LegacyVesselInfo } from '../../entities/legacyVessel/model/types';
|
||||
import type { SubcableGeoJson } from '../../entities/subcable/model/types';
|
||||
import type { ZonesGeoJson } from '../../entities/zone/api/useZones';
|
||||
import type { MapToggleState } from '../../features/mapToggles/MapToggles';
|
||||
import type { FcLink, FleetCircle, PairLink } from '../../features/legacyDashboard/model/types';
|
||||
import type { MapStyleSettings } from '../../features/mapSettings/types';
|
||||
|
||||
export type Map3DSettings = {
|
||||
showSeamark: boolean;
|
||||
showShips: boolean;
|
||||
showDensity: boolean;
|
||||
};
|
||||
|
||||
export type BaseMapId = 'enhanced' | 'legacy';
|
||||
export type MapProjectionId = 'mercator' | 'globe';
|
||||
|
||||
export interface Map3DProps {
|
||||
targets: AisTarget[];
|
||||
zones: ZonesGeoJson | null;
|
||||
selectedMmsi: number | null;
|
||||
hoveredMmsiSet?: number[];
|
||||
hoveredFleetMmsiSet?: number[];
|
||||
hoveredPairMmsiSet?: number[];
|
||||
hoveredFleetOwnerKey?: string | null;
|
||||
highlightedMmsiSet?: number[];
|
||||
settings: Map3DSettings;
|
||||
baseMap: BaseMapId;
|
||||
projection: MapProjectionId;
|
||||
overlays: MapToggleState;
|
||||
onSelectMmsi: (mmsi: number | null) => void;
|
||||
onToggleHighlightMmsi?: (mmsi: number) => void;
|
||||
onViewBboxChange?: (bbox: [number, number, number, number]) => void;
|
||||
legacyHits?: Map<number, LegacyVesselInfo> | null;
|
||||
pairLinks?: PairLink[];
|
||||
fcLinks?: FcLink[];
|
||||
fleetCircles?: FleetCircle[];
|
||||
onProjectionLoadingChange?: (loading: boolean) => void;
|
||||
fleetFocus?: {
|
||||
id: string | number;
|
||||
center: [number, number];
|
||||
zoom?: number;
|
||||
};
|
||||
onHoverFleet?: (ownerKey: string | null, fleetMmsiSet: number[]) => void;
|
||||
onClearFleetHover?: () => void;
|
||||
onHoverMmsi?: (mmsiList: number[]) => void;
|
||||
onClearMmsiHover?: () => void;
|
||||
onHoverPair?: (mmsiList: number[]) => void;
|
||||
onClearPairHover?: () => void;
|
||||
subcableGeo?: SubcableGeoJson | null;
|
||||
hoveredCableId?: string | null;
|
||||
onHoverCable?: (cableId: string | null) => void;
|
||||
onClickCable?: (cableId: string | null) => void;
|
||||
mapStyleSettings?: MapStyleSettings;
|
||||
}
|
||||
|
||||
export type DashSeg = {
|
||||
from: [number, number];
|
||||
to: [number, number];
|
||||
suspicious: boolean;
|
||||
distanceNm?: number;
|
||||
fromMmsi?: number;
|
||||
toMmsi?: number;
|
||||
};
|
||||
|
||||
export type PairRangeCircle = {
|
||||
center: [number, number]; // [lon, lat]
|
||||
radiusNm: number;
|
||||
warn: boolean;
|
||||
aMmsi: number;
|
||||
bMmsi: number;
|
||||
distanceNm: number;
|
||||
};
|
||||
|
||||
export type BathyZoomRange = {
|
||||
id: string;
|
||||
mercator: [number, number];
|
||||
globe: [number, number];
|
||||
};
|
||||
@ -1,15 +1,97 @@
|
||||
import { useMemo, type MouseEvent } from "react";
|
||||
import { VESSEL_TYPES } from "../../entities/vessel/model/meta";
|
||||
import type { DerivedLegacyVessel } from "../../features/legacyDashboard/model/types";
|
||||
import { haversineNm } from "../../shared/lib/geo/haversineNm";
|
||||
import { OVERLAY_RGB, rgbToHex, rgba } from "../../shared/lib/map/palette";
|
||||
|
||||
type FleetSortMode = "count" | "range";
|
||||
|
||||
type Props = {
|
||||
selectedVessel: DerivedLegacyVessel | null;
|
||||
vessels: DerivedLegacyVessel[];
|
||||
fleetVessels: DerivedLegacyVessel[];
|
||||
onSelectMmsi: (mmsi: number) => void;
|
||||
onToggleHighlightMmsi: (mmsi: number) => void;
|
||||
onHoverMmsi: (mmsis: number[]) => void;
|
||||
onClearHover: () => void;
|
||||
onHoverPair: (mmsis: number[]) => void;
|
||||
onClearPairHover: () => void;
|
||||
onHoverFleet: (ownerKey: string | null, mmsis: number[]) => void;
|
||||
onClearFleetHover: () => void;
|
||||
hoveredFleetOwnerKey?: string | null;
|
||||
hoveredFleetMmsiSet?: number[];
|
||||
onContextMenuFleet?: (ownerKey: string, mmsis: number[]) => void;
|
||||
fleetSortMode?: FleetSortMode;
|
||||
};
|
||||
|
||||
export function RelationsPanel({ selectedVessel, vessels, fleetVessels, onSelectMmsi }: Props) {
|
||||
export function RelationsPanel({
|
||||
selectedVessel,
|
||||
vessels,
|
||||
fleetVessels,
|
||||
onSelectMmsi,
|
||||
onToggleHighlightMmsi,
|
||||
onHoverMmsi,
|
||||
onClearHover,
|
||||
onHoverPair,
|
||||
onClearPairHover,
|
||||
onHoverFleet,
|
||||
onClearFleetHover,
|
||||
hoveredFleetOwnerKey,
|
||||
hoveredFleetMmsiSet,
|
||||
onContextMenuFleet,
|
||||
fleetSortMode = "count",
|
||||
}: Props) {
|
||||
const handlePrimaryAction = (e: MouseEvent, mmsi: number) => {
|
||||
if (e.shiftKey || e.ctrlKey || e.metaKey) {
|
||||
onToggleHighlightMmsi(mmsi);
|
||||
return;
|
||||
}
|
||||
onSelectMmsi(mmsi);
|
||||
};
|
||||
|
||||
const clearAllHovers = () => {
|
||||
onClearHover();
|
||||
onClearPairHover();
|
||||
onClearFleetHover();
|
||||
};
|
||||
|
||||
const hoveredFleetMmsis = useMemo(() => new Set(hoveredFleetMmsiSet ?? []), [hoveredFleetMmsiSet]);
|
||||
|
||||
const isFleetHighlightByOwner = (ownerKey: string | null) =>
|
||||
hoveredFleetOwnerKey != null && ownerKey != null && hoveredFleetOwnerKey === ownerKey;
|
||||
|
||||
const isVesselHighlight = (mmsi: number, ownerKey: string | null) =>
|
||||
hoveredFleetMmsis.has(mmsi) || isFleetHighlightByOwner(ownerKey);
|
||||
|
||||
const topFleets = useMemo(() => {
|
||||
const group = new Map<string, DerivedLegacyVessel[]>();
|
||||
for (const v of fleetVessels) {
|
||||
if (!v.ownerKey) continue;
|
||||
const list = group.get(v.ownerKey);
|
||||
if (list) list.push(v);
|
||||
else group.set(v.ownerKey, [v]);
|
||||
}
|
||||
|
||||
return Array.from(group.entries())
|
||||
.map(([ownerKey, vs]) => {
|
||||
const lon = vs.reduce((sum, v) => sum + v.lon, 0) / vs.length;
|
||||
const lat = vs.reduce((sum, v) => sum + v.lat, 0) / vs.length;
|
||||
const radiusNm = vs.reduce((max, v) => {
|
||||
const d = haversineNm(lat, lon, v.lat, v.lon);
|
||||
return Math.max(max, d);
|
||||
}, 0);
|
||||
|
||||
return { ownerKey, vs, radiusNm };
|
||||
})
|
||||
.filter((x) => x.vs.length >= 3)
|
||||
.sort((a, b) => {
|
||||
if (fleetSortMode === "range") {
|
||||
return b.radiusNm - a.radiusNm || b.vs.length - a.vs.length;
|
||||
}
|
||||
return b.vs.length - a.vs.length || b.radiusNm - a.radiusNm;
|
||||
});
|
||||
}, [fleetVessels, fleetSortMode]);
|
||||
|
||||
if (selectedVessel) {
|
||||
const v = selectedVessel;
|
||||
const meta = VESSEL_TYPES[v.shipCode];
|
||||
@ -19,7 +101,7 @@ export function RelationsPanel({ selectedVessel, vessels, fleetVessels, onSelect
|
||||
const fcNearby = vessels.filter((fc) => fc.shipCode === "FC" && fc.mmsi !== v.mmsi && haversineNm(fc.lat, fc.lon, v.lat, v.lon) < 5);
|
||||
|
||||
return (
|
||||
<div className="rel-panel">
|
||||
<div className="rel-panel" onMouseLeave={clearAllHovers}>
|
||||
<div className="rel-header">
|
||||
<span style={{ fontSize: 14 }}>{meta.icon}</span>
|
||||
<span style={{ fontSize: 11, fontWeight: 800, color: meta.color }}>{v.permitNo}</span>
|
||||
@ -59,19 +141,29 @@ export function RelationsPanel({ selectedVessel, vessels, fleetVessels, onSelect
|
||||
<>
|
||||
<div className="rel-line">
|
||||
<div className="dot" style={{ background: meta.color }} />
|
||||
<span style={{ fontSize: 9, fontWeight: 600, cursor: "pointer" }} onClick={() => onSelectMmsi(v.mmsi)}>
|
||||
<span
|
||||
style={{ fontSize: 9, fontWeight: 600, cursor: "pointer" }}
|
||||
onMouseEnter={() => onHoverPair([v.mmsi, pair.mmsi])}
|
||||
onMouseLeave={onClearPairHover}
|
||||
onClick={(e) => handlePrimaryAction(e, v.mmsi)}
|
||||
>
|
||||
{v.permitNo}
|
||||
</span>
|
||||
<div className="rel-link">{warn ? "⚠" : "⟷"}</div>
|
||||
<div className="dot" style={{ background: pairMeta.color }} />
|
||||
<span style={{ fontSize: 9, fontWeight: 600, cursor: "pointer" }} onClick={() => onSelectMmsi(pair.mmsi)}>
|
||||
<span
|
||||
style={{ fontSize: 9, fontWeight: 600, cursor: "pointer" }}
|
||||
onMouseEnter={() => onHoverPair([v.mmsi, pair.mmsi])}
|
||||
onMouseLeave={onClearPairHover}
|
||||
onClick={(e) => handlePrimaryAction(e, pair.mmsi)}
|
||||
>
|
||||
{pair.permitNo}
|
||||
</span>
|
||||
<span
|
||||
className="rel-dist"
|
||||
style={{
|
||||
background: warn ? "#F59E0B22" : "#22C55E22",
|
||||
color: warn ? "#F59E0B" : "#22C55E",
|
||||
background: warn ? rgba(OVERLAY_RGB.pairWarn, 0.13) : "#22C55E22",
|
||||
color: warn ? rgbToHex(OVERLAY_RGB.pairWarn) : "#22C55E",
|
||||
}}
|
||||
>
|
||||
{dist.toFixed(2)}NM
|
||||
@ -98,10 +190,15 @@ export function RelationsPanel({ selectedVessel, vessels, fleetVessels, onSelect
|
||||
return (
|
||||
<div key={fc.mmsi} className="rel-line">
|
||||
<div className="dot" style={{ background: VESSEL_TYPES.FC.color }} />
|
||||
<span style={{ fontSize: 9, fontWeight: 600, cursor: "pointer" }} onClick={() => onSelectMmsi(fc.mmsi)}>
|
||||
<span
|
||||
style={{ fontSize: 9, fontWeight: 600, cursor: "pointer" }}
|
||||
onMouseEnter={() => onHoverPair([v.mmsi, fc.mmsi])}
|
||||
onMouseLeave={onClearPairHover}
|
||||
onClick={(e) => handlePrimaryAction(e, fc.mmsi)}
|
||||
>
|
||||
{fc.permitNo}
|
||||
</span>
|
||||
<span className="rel-dist" style={{ background: "#D9770622", color: "#D97706" }}>
|
||||
<span className="rel-dist" style={{ background: rgba(OVERLAY_RGB.fcTransfer, 0.13), color: rgbToHex(OVERLAY_RGB.fcTransfer) }}>
|
||||
{dist.toFixed(1)}NM
|
||||
</span>
|
||||
{isSameOwner ? (
|
||||
@ -144,7 +241,14 @@ export function RelationsPanel({ selectedVessel, vessels, fleetVessels, onSelect
|
||||
{sameOwner.slice(0, 8).map((sv) => {
|
||||
const m = VESSEL_TYPES[sv.shipCode];
|
||||
return (
|
||||
<div key={sv.mmsi} className="fleet-vessel" onClick={() => onSelectMmsi(sv.mmsi)} style={{ cursor: "pointer" }}>
|
||||
<div
|
||||
key={sv.mmsi}
|
||||
className={`fleet-vessel ${isVesselHighlight(sv.mmsi, v.ownerKey) ? "hl" : ""}`}
|
||||
onMouseEnter={() => onHoverFleet(v.ownerKey, [v.mmsi, ...sameOwner.map((x) => x.mmsi), sv.mmsi])}
|
||||
onMouseLeave={onClearFleetHover}
|
||||
onClick={(e) => handlePrimaryAction(e, sv.mmsi)}
|
||||
style={{ cursor: "pointer" }}
|
||||
>
|
||||
<div className="dot" style={{ background: m.color }} />
|
||||
<span style={{ color: m.color, fontWeight: 600 }}>{sv.shipCode}</span> {sv.permitNo}
|
||||
<span style={{ color: "var(--muted)" }}>
|
||||
@ -160,56 +264,53 @@ export function RelationsPanel({ selectedVessel, vessels, fleetVessels, onSelect
|
||||
);
|
||||
}
|
||||
|
||||
// No vessel selected: show top fleets
|
||||
if (fleetVessels.length === 0) {
|
||||
return <div style={{ fontSize: 11, color: "var(--muted)" }}>(현재 지도에 표시중인 대상 선박이 없습니다)</div>;
|
||||
}
|
||||
|
||||
const group = new Map<string, DerivedLegacyVessel[]>();
|
||||
for (const v of fleetVessels) {
|
||||
if (!v.ownerKey) continue;
|
||||
const list = group.get(v.ownerKey);
|
||||
if (list) list.push(v);
|
||||
else group.set(v.ownerKey, [v]);
|
||||
}
|
||||
|
||||
const topFleets = Array.from(group.entries())
|
||||
.map(([ownerKey, vs]) => ({ ownerKey, vs }))
|
||||
.filter((x) => x.vs.length >= 3)
|
||||
.sort((a, b) => b.vs.length - a.vs.length)
|
||||
.slice(0, 5);
|
||||
|
||||
if (topFleets.length === 0) {
|
||||
return <div style={{ fontSize: 11, color: "var(--muted)" }}>(표시 중인 선단(3척 이상) 없음)</div>;
|
||||
}
|
||||
|
||||
return (
|
||||
<div>
|
||||
<div onMouseLeave={clearAllHovers}>
|
||||
{topFleets.map(({ ownerKey, vs }) => {
|
||||
const displayOwner = vs.find((v) => v.ownerCn)?.ownerCn || vs.find((v) => v.ownerRoman)?.ownerRoman || ownerKey;
|
||||
const displayTitle = ownerKey && displayOwner !== ownerKey ? `${displayOwner} (${ownerKey})` : displayOwner;
|
||||
const isHighlightedFleetRow = isFleetHighlightByOwner(ownerKey) || vs.some((v) => hoveredFleetMmsis.has(v.mmsi));
|
||||
const codes: Record<string, number> = {};
|
||||
for (const v of vs) codes[v.shipCode] = (codes[v.shipCode] ?? 0) + 1;
|
||||
|
||||
return (
|
||||
<div key={ownerKey} className="fleet-card">
|
||||
<div className="fleet-owner">
|
||||
<div
|
||||
key={ownerKey}
|
||||
className={`fleet-card ${isHighlightedFleetRow ? "hl" : ""}`}
|
||||
onContextMenu={(e) => {
|
||||
e.preventDefault();
|
||||
onContextMenuFleet?.(ownerKey, vs.map((x) => x.mmsi));
|
||||
}}
|
||||
onMouseEnter={() => onHoverFleet(ownerKey, vs.map((v) => v.mmsi))}
|
||||
onMouseLeave={onClearFleetHover}
|
||||
>
|
||||
<div className={`fleet-owner ${isHighlightedFleetRow ? "hl" : ""}`}>
|
||||
🏢{" "}
|
||||
<span
|
||||
style={{
|
||||
display: "inline-block",
|
||||
maxWidth: 240,
|
||||
verticalAlign: "bottom",
|
||||
whiteSpace: "nowrap",
|
||||
overflow: "hidden",
|
||||
textOverflow: "ellipsis",
|
||||
}}
|
||||
title={displayTitle}
|
||||
>
|
||||
{displayOwner}
|
||||
</span>{" "}
|
||||
<span style={{ fontSize: 8, color: "var(--muted)" }}>{vs.length}척</span>
|
||||
</div>
|
||||
<span
|
||||
style={{
|
||||
display: "inline-block",
|
||||
maxWidth: 240,
|
||||
verticalAlign: "bottom",
|
||||
whiteSpace: "nowrap",
|
||||
overflow: "hidden",
|
||||
textOverflow: "ellipsis",
|
||||
color: "var(--text)",
|
||||
marginRight: 4,
|
||||
}}
|
||||
title={displayTitle}
|
||||
>
|
||||
{displayTitle}
|
||||
</span>{" "}
|
||||
<span style={{ fontSize: 8, color: "var(--muted)" }}>{vs.length}척</span>
|
||||
</div>
|
||||
<div style={{ display: "flex", gap: 4, flexWrap: "wrap", marginBottom: 3 }}>
|
||||
{Object.entries(codes).map(([c, n]) => {
|
||||
const meta = VESSEL_TYPES[c as keyof typeof VESSEL_TYPES];
|
||||
@ -234,11 +335,15 @@ export function RelationsPanel({ selectedVessel, vessels, fleetVessels, onSelect
|
||||
<div style={{ display: "flex", alignItems: "center", gap: 2, flexWrap: "wrap" }}>
|
||||
{vs.slice(0, 18).map((v) => {
|
||||
const m = VESSEL_TYPES[v.shipCode];
|
||||
const text = v.shipCode === "FC" ? "F" : v.shipCode === "PT" ? "M" : v.shipCode === "PT-S" ? "S" : v.shipCode[0];
|
||||
const text =
|
||||
v.shipCode === "FC" ? "F" : v.shipCode === "PT" ? "M" : v.shipCode === "PT-S" ? "S" : v.shipCode[0];
|
||||
return (
|
||||
<span key={v.mmsi} style={{ display: "inline-flex", alignItems: "center", gap: 2 }}>
|
||||
<div
|
||||
onClick={() => onSelectMmsi(v.mmsi)}
|
||||
className={`fleet-dot ${isVesselHighlight(v.mmsi, ownerKey) ? "hl" : ""}`}
|
||||
onMouseEnter={() => onHoverMmsi([v.mmsi])}
|
||||
onMouseLeave={onClearHover}
|
||||
onClick={(e) => handlePrimaryAction(e, v.mmsi)}
|
||||
style={{
|
||||
cursor: "pointer",
|
||||
width: 16,
|
||||
|
||||
107
apps/web/src/widgets/subcableInfo/SubcableInfoPanel.tsx
Normal file
107
apps/web/src/widgets/subcableInfo/SubcableInfoPanel.tsx
Normal file
@ -0,0 +1,107 @@
|
||||
import type { SubcableDetail } from '../../entities/subcable/model/types';
|
||||
|
||||
interface Props {
|
||||
detail: SubcableDetail;
|
||||
color?: string;
|
||||
onClose: () => void;
|
||||
}
|
||||
|
||||
export function SubcableInfoPanel({ detail, color, onClose }: Props) {
|
||||
const landingCount = detail.landing_points.length;
|
||||
const countries = [...new Set(detail.landing_points.map((lp) => lp.country).filter(Boolean))];
|
||||
|
||||
return (
|
||||
<div className="map-info" style={{ maxWidth: 340 }}>
|
||||
<button className="close-btn" onClick={onClose} aria-label="close">
|
||||
✕
|
||||
</button>
|
||||
|
||||
<div style={{ marginBottom: 8 }}>
|
||||
<div style={{ display: 'flex', alignItems: 'center', gap: 8 }}>
|
||||
{color && (
|
||||
<div
|
||||
style={{
|
||||
width: 12,
|
||||
height: 12,
|
||||
borderRadius: 3,
|
||||
backgroundColor: color,
|
||||
flexShrink: 0,
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
<div style={{ fontSize: 16, fontWeight: 900, color: 'var(--accent)' }}>{detail.name}</div>
|
||||
</div>
|
||||
<div style={{ fontSize: 10, color: 'var(--muted)', marginTop: 2 }}>
|
||||
Submarine Cable{detail.is_planned ? ' (Planned)' : ''}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="ir">
|
||||
<span className="il">길이</span>
|
||||
<span className="iv">{detail.length || '-'}</span>
|
||||
</div>
|
||||
<div className="ir">
|
||||
<span className="il">개통</span>
|
||||
<span className="iv">{detail.rfs || '-'}</span>
|
||||
</div>
|
||||
{detail.owners && (
|
||||
<div className="ir" style={{ alignItems: 'flex-start' }}>
|
||||
<span className="il">운영사</span>
|
||||
<span className="iv" style={{ wordBreak: 'break-word' }}>
|
||||
{detail.owners}
|
||||
</span>
|
||||
</div>
|
||||
)}
|
||||
{detail.suppliers && (
|
||||
<div className="ir">
|
||||
<span className="il">공급사</span>
|
||||
<span className="iv">{detail.suppliers}</span>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{landingCount > 0 && (
|
||||
<div style={{ marginTop: 8 }}>
|
||||
<div style={{ fontSize: 10, fontWeight: 700, color: 'var(--muted)', marginBottom: 4 }}>
|
||||
Landing Points ({landingCount}) · {countries.length} countries
|
||||
</div>
|
||||
<div
|
||||
style={{
|
||||
maxHeight: 140,
|
||||
overflowY: 'auto',
|
||||
fontSize: 10,
|
||||
lineHeight: 1.6,
|
||||
color: 'var(--text)',
|
||||
}}
|
||||
>
|
||||
{detail.landing_points.map((lp) => (
|
||||
<div key={lp.id}>
|
||||
<span style={{ color: 'var(--muted)' }}>{lp.country}</span>{' '}
|
||||
<b>{lp.name}</b>
|
||||
{lp.is_tbd && <span style={{ color: '#F59E0B', marginLeft: 4 }}>TBD</span>}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{detail.notes && (
|
||||
<div style={{ marginTop: 8, fontSize: 10, color: 'var(--muted)', fontStyle: 'italic' }}>
|
||||
{detail.notes}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{detail.url && (
|
||||
<div style={{ marginTop: 8 }}>
|
||||
<a
|
||||
href={detail.url}
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
style={{ fontSize: 10, color: 'var(--accent)' }}
|
||||
>
|
||||
Official website ↗
|
||||
</a>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@ -1,17 +1,38 @@
|
||||
import { VESSEL_TYPES } from "../../entities/vessel/model/meta";
|
||||
import type { DerivedLegacyVessel } from "../../features/legacyDashboard/model/types";
|
||||
import type { MouseEvent } from "react";
|
||||
|
||||
type Props = {
|
||||
vessels: DerivedLegacyVessel[];
|
||||
selectedMmsi: number | null;
|
||||
highlightedMmsiSet?: number[];
|
||||
onToggleHighlightMmsi: (mmsi: number) => void;
|
||||
onSelectMmsi: (mmsi: number) => void;
|
||||
onHoverMmsi?: (mmsi: number) => void;
|
||||
onClearHover?: () => void;
|
||||
};
|
||||
|
||||
function isFiniteNumber(x: unknown): x is number {
|
||||
export function VesselList({
|
||||
vessels,
|
||||
selectedMmsi,
|
||||
highlightedMmsiSet = [],
|
||||
onToggleHighlightMmsi,
|
||||
onSelectMmsi,
|
||||
onHoverMmsi,
|
||||
onClearHover,
|
||||
}: Props) {
|
||||
const handlePrimaryAction = (e: MouseEvent, mmsi: number) => {
|
||||
if (e.shiftKey || e.ctrlKey || e.metaKey) {
|
||||
onToggleHighlightMmsi(mmsi);
|
||||
return;
|
||||
}
|
||||
onSelectMmsi(mmsi);
|
||||
};
|
||||
|
||||
function isFiniteNumber(x: unknown): x is number {
|
||||
return typeof x === "number" && Number.isFinite(x);
|
||||
}
|
||||
|
||||
export function VesselList({ vessels, selectedMmsi, onSelectMmsi }: Props) {
|
||||
const sorted = vessels
|
||||
.slice()
|
||||
.sort((a, b) => (isFiniteNumber(b.sog) ? b.sog : -1) - (isFiniteNumber(a.sog) ? a.sog : -1))
|
||||
@ -29,13 +50,15 @@ export function VesselList({ vessels, selectedMmsi, onSelectMmsi }: Props) {
|
||||
const speedColor = inRange ? "#22C55E" : (v.sog ?? 0) > 5 ? "#3B82F6" : "var(--muted)";
|
||||
const hasPair = v.pairPermitNo ? "⛓" : "";
|
||||
const sel = selectedMmsi === v.mmsi;
|
||||
const hl = highlightedMmsiSet.includes(v.mmsi);
|
||||
|
||||
return (
|
||||
<div
|
||||
key={v.mmsi}
|
||||
className="vi"
|
||||
onClick={() => onSelectMmsi(v.mmsi)}
|
||||
style={sel ? { background: "rgba(59,130,246,.12)", border: "1px solid rgba(59,130,246,.45)" } : undefined}
|
||||
className={`vi ${sel ? "sel" : ""} ${hl ? "hl" : ""}`}
|
||||
onClick={(e) => handlePrimaryAction(e, v.mmsi)}
|
||||
onMouseEnter={() => onHoverMmsi?.(v.mmsi)}
|
||||
onMouseLeave={() => onClearHover?.()}
|
||||
title={v.name}
|
||||
>
|
||||
<div className="dot" style={{ background: meta.color, boxShadow: v.state.isFishing ? `0 0 3px ${meta.color}` : undefined }} />
|
||||
@ -56,4 +79,3 @@ export function VesselList({ vessels, selectedMmsi, onSelectMmsi }: Props) {
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
4710
package-lock.json
generated
Normal file
4710
package-lock.json
generated
Normal file
파일 크기가 너무 크기때문에 변경 상태를 표시하지 않습니다.
Load Diff
16
package.json
16
package.json
@ -1,13 +1,17 @@
|
||||
{
|
||||
"name": "wing-fleet-dashboard",
|
||||
"private": true,
|
||||
"packageManager": "pnpm@10.29.3",
|
||||
"workspaces": ["apps/*"],
|
||||
"scripts": {
|
||||
"dev": "pnpm -r --parallel dev",
|
||||
"dev:web": "pnpm --filter @wing/web dev",
|
||||
"dev:api": "pnpm --filter @wing/api dev",
|
||||
"build": "pnpm -r build",
|
||||
"prepare:data": "node scripts/prepare-zones.mjs && node scripts/prepare-legacy.mjs"
|
||||
"dev": "npm run dev:web & npm run dev:api",
|
||||
"dev:web": "npm -w @wing/web run dev",
|
||||
"dev:api": "npm -w @wing/api run dev",
|
||||
"build": "npm -w @wing/web run build && npm -w @wing/api run build",
|
||||
"build:web": "npm -w @wing/web run build",
|
||||
"build:api": "npm -w @wing/api run build",
|
||||
"lint": "npm -w @wing/web run lint",
|
||||
"prepare:data": "node scripts/prepare-zones.mjs && node scripts/prepare-legacy.mjs",
|
||||
"prepare:subcables": "node scripts/prepare-subcables.mjs"
|
||||
},
|
||||
"devDependencies": {
|
||||
"xlsx": "^0.18.5"
|
||||
|
||||
3001
pnpm-lock.yaml
generated
3001
pnpm-lock.yaml
generated
파일 크기가 너무 크기때문에 변경 상태를 표시하지 않습니다.
Load Diff
@ -1,4 +0,0 @@
|
||||
packages:
|
||||
- "apps/*"
|
||||
- "packages/*"
|
||||
|
||||
176
scripts/prepare-subcables.mjs
Normal file
176
scripts/prepare-subcables.mjs
Normal file
@ -0,0 +1,176 @@
|
||||
import fs from "node:fs/promises";
|
||||
import path from "node:path";
|
||||
import { fileURLToPath } from "node:url";
|
||||
|
||||
const __filename = fileURLToPath(import.meta.url);
|
||||
const __dirname = path.dirname(__filename);
|
||||
|
||||
const OUT_DIR = path.resolve(__dirname, "..", "apps", "web", "public", "data", "subcables");
|
||||
const GEO_URL = "https://www.submarinecablemap.com/api/v3/cable/cable-geo.json";
|
||||
const DETAILS_URL_BASE = "https://www.submarinecablemap.com/api/v3/cable/";
|
||||
|
||||
const CONCURRENCY = Math.max(1, Math.min(24, Number(process.env.CONCURRENCY || 12)));
|
||||
const TIMEOUT_MS = Math.max(5_000, Math.min(60_000, Number(process.env.TIMEOUT_MS || 20_000)));
|
||||
|
||||
function sleep(ms) {
|
||||
return new Promise((resolve) => setTimeout(resolve, ms));
|
||||
}
|
||||
|
||||
async function fetchText(url, { timeoutMs = TIMEOUT_MS } = {}) {
|
||||
const controller = new AbortController();
|
||||
const timeout = setTimeout(() => controller.abort(), timeoutMs);
|
||||
try {
|
||||
const res = await fetch(url, {
|
||||
signal: controller.signal,
|
||||
headers: {
|
||||
accept: "application/json",
|
||||
},
|
||||
});
|
||||
const text = await res.text();
|
||||
const contentType = res.headers.get("content-type") || "";
|
||||
if (!res.ok) {
|
||||
throw new Error(`HTTP ${res.status} (${res.statusText})`);
|
||||
}
|
||||
return { text, contentType };
|
||||
} finally {
|
||||
clearTimeout(timeout);
|
||||
}
|
||||
}
|
||||
|
||||
async function fetchJson(url) {
|
||||
const { text, contentType } = await fetchText(url);
|
||||
if (!contentType.toLowerCase().includes("application/json")) {
|
||||
const snippet = text.slice(0, 200).replace(/\s+/g, " ").trim();
|
||||
throw new Error(`Unexpected content-type (${contentType || "unknown"}): ${snippet || "<empty>"}`);
|
||||
}
|
||||
try {
|
||||
return JSON.parse(text);
|
||||
} catch (e) {
|
||||
throw new Error(`Invalid JSON: ${e instanceof Error ? e.message : String(e)}`);
|
||||
}
|
||||
}
|
||||
|
||||
async function fetchJsonWithRetry(url, attempts = 2) {
|
||||
let lastErr = null;
|
||||
for (let i = 0; i < attempts; i += 1) {
|
||||
try {
|
||||
return await fetchJson(url);
|
||||
} catch (e) {
|
||||
lastErr = e;
|
||||
if (i < attempts - 1) {
|
||||
await sleep(250 * (i + 1));
|
||||
}
|
||||
}
|
||||
}
|
||||
throw lastErr;
|
||||
}
|
||||
|
||||
function pickCableDetails(raw) {
|
||||
const obj = raw && typeof raw === "object" ? raw : {};
|
||||
const landingPoints = Array.isArray(obj.landing_points) ? obj.landing_points : [];
|
||||
return {
|
||||
id: String(obj.id || ""),
|
||||
name: String(obj.name || ""),
|
||||
length: obj.length == null ? null : String(obj.length),
|
||||
rfs: obj.rfs == null ? null : String(obj.rfs),
|
||||
rfs_year: typeof obj.rfs_year === "number" ? obj.rfs_year : null,
|
||||
is_planned: Boolean(obj.is_planned),
|
||||
owners: obj.owners == null ? null : String(obj.owners),
|
||||
suppliers: obj.suppliers == null ? null : String(obj.suppliers),
|
||||
landing_points: landingPoints.map((lp) => {
|
||||
const p = lp && typeof lp === "object" ? lp : {};
|
||||
return {
|
||||
id: String(p.id || ""),
|
||||
name: String(p.name || ""),
|
||||
country: String(p.country || ""),
|
||||
is_tbd: p.is_tbd === true,
|
||||
};
|
||||
}),
|
||||
notes: obj.notes == null ? null : String(obj.notes),
|
||||
url: obj.url == null ? null : String(obj.url),
|
||||
};
|
||||
}
|
||||
|
||||
async function main() {
|
||||
await fs.mkdir(OUT_DIR, { recursive: true });
|
||||
|
||||
console.log(`[subcables] fetching geojson: ${GEO_URL}`);
|
||||
const geo = await fetchJsonWithRetry(GEO_URL, 3);
|
||||
const geoPath = path.join(OUT_DIR, "cable-geo.json");
|
||||
await fs.writeFile(geoPath, JSON.stringify(geo));
|
||||
|
||||
const features = Array.isArray(geo?.features) ? geo.features : [];
|
||||
const ids = Array.from(
|
||||
new Set(
|
||||
features
|
||||
.map((f) => f?.properties?.id)
|
||||
.filter((v) => typeof v === "string" && v.trim().length > 0)
|
||||
.map((v) => v.trim()),
|
||||
),
|
||||
).sort();
|
||||
|
||||
console.log(`[subcables] cables: ${ids.length} (concurrency=${CONCURRENCY}, timeoutMs=${TIMEOUT_MS})`);
|
||||
|
||||
const byId = {};
|
||||
const failures = [];
|
||||
let cursor = 0;
|
||||
let completed = 0;
|
||||
const startedAt = Date.now();
|
||||
|
||||
const worker = async () => {
|
||||
for (;;) {
|
||||
const idx = cursor;
|
||||
cursor += 1;
|
||||
if (idx >= ids.length) return;
|
||||
const id = ids[idx];
|
||||
const url = new URL(`${id}.json`, DETAILS_URL_BASE).toString();
|
||||
try {
|
||||
const raw = await fetchJsonWithRetry(url, 2);
|
||||
const picked = pickCableDetails(raw);
|
||||
if (!picked.id) {
|
||||
throw new Error("Missing id in details response");
|
||||
}
|
||||
byId[id] = picked;
|
||||
} catch (e) {
|
||||
failures.push({ id, error: e instanceof Error ? e.message : String(e) });
|
||||
} finally {
|
||||
completed += 1;
|
||||
if (completed % 25 === 0 || completed === ids.length) {
|
||||
const sec = Math.max(1, Math.round((Date.now() - startedAt) / 1000));
|
||||
const rate = (completed / sec).toFixed(1);
|
||||
console.log(`[subcables] ${completed}/${ids.length} (${rate}/s)`);
|
||||
}
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
await Promise.all(Array.from({ length: CONCURRENCY }, () => worker()));
|
||||
|
||||
const detailsOut = {
|
||||
version: 1,
|
||||
generated_at: new Date().toISOString(),
|
||||
by_id: byId,
|
||||
};
|
||||
const detailsPath = path.join(OUT_DIR, "cable-details.min.json");
|
||||
await fs.writeFile(detailsPath, JSON.stringify(detailsOut));
|
||||
|
||||
if (failures.length > 0) {
|
||||
console.error(`[subcables] failures: ${failures.length}`);
|
||||
for (const f of failures.slice(0, 30)) {
|
||||
console.error(`- ${f.id}: ${f.error}`);
|
||||
}
|
||||
if (failures.length > 30) {
|
||||
console.error(`- ... +${failures.length - 30} more`);
|
||||
}
|
||||
process.exitCode = 1;
|
||||
}
|
||||
|
||||
console.log(`[subcables] wrote: ${geoPath}`);
|
||||
console.log(`[subcables] wrote: ${detailsPath}`);
|
||||
}
|
||||
|
||||
main().catch((e) => {
|
||||
console.error(`[subcables] fatal: ${e instanceof Error ? e.stack || e.message : String(e)}`);
|
||||
process.exitCode = 1;
|
||||
});
|
||||
|
||||
불러오는 중...
Reference in New Issue
Block a user