Compare commits

..

1 커밋

작성자 SHA1 메시지 날짜
5591ed5504 feat(announcement): 공지 팝업 모듈 + Ocean 기본값 수정
- features/announcement/ 자체 완결 블록 (타입, 상수, 훅, 모달 UI)
- useAnnouncementPopup: lastSeenAnnouncementId 기반 계정별 1회 표시
- AnnouncementModal: 업데이트 안내 (Ocean 맵/자유시점/선박사진)
- Ocean DEFAULT_OCEAN_MAP_SETTINGS: depthStops 빈 배열 (네이티브 색상 유지)

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-20 23:40:31 +09:00
76개의 변경된 파일1757개의 추가작업 그리고 3600개의 파일을 삭제

파일 보기

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

파일 보기

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

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

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

파일 보기

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

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

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

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

파일 보기

@ -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

파일 보기

@ -0,0 +1,8 @@
#!/bin/bash
# PreCompact hook: systemMessage만 지원 (hookSpecificOutput 사용 불가)
INPUT=$(cat)
cat <<RESP
{
"systemMessage": "컨텍스트 압축이 시작됩니다. 반드시 다음을 수행하세요:\n\n1. memory/MEMORY.md - 핵심 작업 상태 갱신 (200줄 이내)\n2. memory/project-snapshot.md - 변경된 패키지/타입 정보 업데이트\n3. memory/project-history.md - 이번 세션 변경사항 추가\n4. memory/api-types.md - API 인터페이스 변경이 있었다면 갱신\n5. 미완료 작업이 있다면 TodoWrite에 남기고 memory에도 기록"
}
RESP

파일 보기

@ -2,30 +2,30 @@
"$schema": "https://json.schemastore.org/claude-code-settings.json",
"permissions": {
"allow": [
"Bash(curl -s *)",
"Bash(fnm *)",
"Bash(git add *)",
"Bash(git branch *)",
"Bash(git checkout *)",
"Bash(git commit *)",
"Bash(git config *)",
"Bash(git diff *)",
"Bash(git fetch *)",
"Bash(git log *)",
"Bash(git merge *)",
"Bash(git pull *)",
"Bash(git remote *)",
"Bash(git rev-parse *)",
"Bash(git show *)",
"Bash(git stash *)",
"Bash(git status)",
"Bash(git tag *)",
"Bash(node *)",
"Bash(npm run *)",
"Bash(npm -w *)",
"Bash(npm install *)",
"Bash(npm run *)",
"Bash(npm test *)",
"Bash(npx *)"
"Bash(npx *)",
"Bash(node *)",
"Bash(git status)",
"Bash(git diff *)",
"Bash(git log *)",
"Bash(git branch *)",
"Bash(git checkout *)",
"Bash(git add *)",
"Bash(git commit *)",
"Bash(git pull *)",
"Bash(git fetch *)",
"Bash(git merge *)",
"Bash(git stash *)",
"Bash(git remote *)",
"Bash(git config *)",
"Bash(git rev-parse *)",
"Bash(git show *)",
"Bash(git tag *)",
"Bash(curl -s *)",
"Bash(fnm *)"
],
"deny": [
"Bash(git push --force*)",
@ -81,8 +81,5 @@
]
}
]
},
"env": {
"CLAUDE_BOT_TOKEN": "ac15488ad66463bd5c4e3be1fa6dd5b2743813c5"
}
}

파일 보기

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

파일 보기

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

파일 보기

@ -1,6 +1,7 @@
---
name: init-project
description: 팀 표준 워크플로우로 프로젝트를 초기화합니다
allowed-tools: "Bash, Read, Write, Edit, Glob, Grep"
argument-hint: "[project-type: java-maven|java-gradle|react-ts|auto]"
---
@ -45,95 +46,73 @@ curl -sf "${GITEA_URL}/gc/template-react-ts/raw/branch/develop/.editorconfig"
### 3. .claude/ 디렉토리 구성
이미 팀 표준 파일이 존재하면 건너뜀. 없는 경우 위의 URL 패턴으로 Gitea에서 다운로드:
- `.claude/settings.json` — 프로젝트 타입별 표준 권한 설정 + env(CLAUDE_BOT_TOKEN 등) + hooks 섹션 (4단계 참조)
- `.claude/settings.json` — 프로젝트 타입별 표준 권한 설정 + hooks 섹션 (4단계 참조)
- `.claude/rules/` — 팀 규칙 파일 (team-policy, git-workflow, code-style, naming, testing)
- `.claude/skills/` — 팀 스킬 (create-mr, fix-issue, sync-team-workflow, init-project)
⚠️ 팀 규칙(.claude/rules/), 에이전트(.claude/agents/), 스킬 6종, 스크립트는 12단계(sync-team-workflow)에서 자동 다운로드된다. 여기서는 settings.json만 설정한다.
### 4. Hook 스크립트 생성
`.claude/scripts/` 디렉토리를 생성하고 다음 스크립트 파일 생성 (chmod +x):
### 3.5. Gitea 토큰 설정
**CLAUDE_BOT_TOKEN** (팀 공용): `settings.json``env` 필드에 이미 포함되어 있음 (3단계에서 설정됨). 별도 조치 불필요.
**GITEA_TOKEN** (개인): `/push`, `/mr`, `/release` 등 Git 스킬에 필요한 개인 토큰.
- `.claude/scripts/on-pre-compact.sh`:
```bash
# 현재 GITEA_TOKEN 설정 여부 확인
if [ -z "$GITEA_TOKEN" ]; then
echo "GITEA_TOKEN 미설정"
#!/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
```
**GITEA_TOKEN이 없는 경우**, 다음 안내를 **AskUserQuestion**으로 표시:
- `.claude/scripts/on-commit.sh`:
**질문**: "GITEA_TOKEN이 설정되지 않았습니다. Gitea 개인 토큰을 생성하시겠습니까?"
- 옵션 1: 토큰 생성 안내 보기 (추천)
- 옵션 2: 이미 있음 (토큰 입력)
- 옵션 3: 나중에 하기
**토큰 생성 안내 선택 시**, 다음 내용을 표시:
```
📋 Gitea 토큰 생성 방법:
1. 브라우저에서 접속:
https://gitea.gc-si.dev/user/settings/applications
2. "Manage Access Tokens" 섹션에서 "Generate New Token" 클릭
3. 입력:
- Token Name: "claude-code" (자유롭게 지정)
- Repository and Organization Access: ✅ All (public, private, and limited)
4. Select permissions (아래 4개만 설정, 나머지는 No Access 유지):
┌─────────────────┬──────────────────┬──────────────────────────────┐
│ 항목 │ 권한 │ 용도 │
├─────────────────┼──────────────────┼──────────────────────────────┤
│ issue │ Read and Write │ /fix-issue 이슈 조회/코멘트 │
│ organization │ Read │ gc 조직 리포 접근 │
│ repository │ Read and Write │ /push, /mr, /release API 호출 │
│ user │ Read │ API 사용자 인증 확인 │
└─────────────────┴──────────────────┴──────────────────────────────┘
5. "Generate Token" 클릭 → ⚠️ 토큰이 한 번만 표시됩니다! 반드시 복사하세요.
```
표시 후 **AskUserQuestion**: "생성한 토큰을 입력하세요"
- 옵션 1: 토큰 입력 (Other로 입력)
- 옵션 2: 나중에 하기
**토큰 입력 시**:
1. Gitea API로 유효성 검증:
```bash
curl -sf "https://gitea.gc-si.dev/api/v1/user" \
-H "Authorization: token <입력된 토큰>"
```
- 성공: `✅ <login> (<full_name>) 인증 확인` 출력
- 실패: `❌ 토큰이 유효하지 않습니다. 다시 확인해주세요.` 출력 → 재입력 요청
2. `.claude/settings.local.json`에 저장 (이 파일은 .gitignore에 포함, 리포 커밋 안됨):
```json
#!/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
{
"env": {
"GITEA_TOKEN": "<입력된 토큰>"
"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
```
기존 `settings.local.json`이 있으면 `env.GITEA_TOKEN`만 추가/갱신.
**나중에 하기 선택 시**: 경고 표시 후 다음 단계로 진행:
```
⚠️ GITEA_TOKEN 없이는 /push, /mr, /release 스킬을 사용할 수 없습니다.
나중에 토큰을 생성하면 .claude/settings.local.json에 다음을 추가하세요:
{ "env": { "GITEA_TOKEN": "your-token-here" } }
```
### 4. Hook 스크립트 설정
⚠️ `.claude/scripts/` 스크립트 파일은 12단계(sync-team-workflow)에서 서버로부터 자동 다운로드된다.
여기서는 `settings.json`에 hooks 섹션만 설정한다.
`.claude/settings.json`에 hooks 섹션이 없으면 추가 (기존 settings.json의 내용에 병합):
```json
@ -187,13 +166,6 @@ git config core.hooksPath .githooks
chmod +x .githooks/*
```
**pre-commit 훅 검증**: `.githooks/pre-commit`을 실행하여 빌드 검증이 정상 동작하는지 확인.
에러 발생 시 (예: 모노레포가 아닌 특수 구조, 빌드 명령 불일치 등):
1. 프로젝트에 맞게 `.githooks/pre-commit`을 커스텀 수정
2. `.claude/workflow-version.json``"custom_pre_commit": true` 추가
3. 이후 `/sync-team-workflow` 실행 시 pre-commit은 덮어쓰지 않고 보존됨
(`commit-msg`, `post-checkout`은 항상 팀 표준으로 동기화)
### 6. 프로젝트 타입별 추가 설정
#### java-maven
@ -221,20 +193,6 @@ chmod +x .githooks/*
*.local
```
**팀 워크플로우 관리 경로** (sync로 생성/관리되는 파일, 리포에 커밋하지 않음):
```
# Team workflow (managed by /sync-team-workflow)
.claude/rules/
.claude/agents/
.claude/skills/push/
.claude/skills/mr/
.claude/skills/create-mr/
.claude/skills/release/
.claude/skills/version/
.claude/skills/fix-issue/
.claude/scripts/
```
### 8. Git exclude 설정
`.git/info/exclude` 파일을 읽고, 기존 내용을 보존하면서 하단에 추가:
@ -278,14 +236,7 @@ curl -sf --max-time 5 "https://gitea.gc-si.dev/gc/template-common/raw/branch/dev
}
```
### 12. 팀 워크플로우 최신화
`/sync-team-workflow`를 자동으로 1회 실행하여 최신 팀 파일(rules, agents, skills 6종, scripts, hooks)을 서버에서 다운로드하고 로컬에 적용한다.
이 단계에서 `.claude/rules/`, `.claude/agents/`, `.claude/skills/push/` 등 팀 관리 파일이 생성된다.
(이 파일들은 7단계에서 .gitignore에 추가되었으므로 리포에 커밋되지 않음)
### 13. 검증 및 요약
### 12. 검증 및 요약
- 생성/수정된 파일 목록 출력
- `git config core.hooksPath` 확인
- 빌드 명령 실행 가능 확인

123
.claude/skills/mr/SKILL.md Normal file
파일 보기

@ -0,0 +1,123 @@
---
name: mr
description: 커밋 + 푸시 + Gitea MR을 한 번에 생성합니다
user-invocable: true
argument-hint: "[target-branch: develop|main] (기본: develop)"
allowed-tools: "Bash, Read, Grep"
---
현재 브랜치의 변경 사항을 커밋+푸시하고, Gitea에 MR을 생성합니다.
타겟 브랜치: $ARGUMENTS (기본: develop)
## 수행 단계
### 1. 사전 검증
```bash
# 현재 브랜치 확인 (main/develop이면 중단)
BRANCH=$(git branch --show-current)
# Gitea remote URL에서 owner/repo 추출
REMOTE_URL=$(git remote get-url origin)
```
- 현재 브랜치가 `main` 또는 `develop`이면: "feature 브랜치에서 실행해주세요" 안내 후 종료
- GITEA_TOKEN 환경변수 확인 (없으면 설정 안내)
### 2. 커밋 + 푸시 (변경 사항이 있을 때만)
```bash
git status --short
```
**커밋되지 않은 변경이 있으면**:
- 변경 범위(파일 목록, 추가/수정/삭제) 요약 표시
- Conventional Commits 형식 커밋 메시지 자동 생성
- **사용자 확인** (AskUserQuestion): 커밋 메시지 수락/수정/취소
- 수락 시: `git add -A``git commit``git push`
**변경이 없으면**:
- 이미 커밋된 내용으로 MR 생성 진행
- 리모트에 push되지 않은 커밋이 있으면 `git push`
### 3. MR 대상 브랜치 결정
타겟 브랜치 후보를 분석하여 표시:
```bash
# develop과의 차이
git log develop..HEAD --oneline 2>/dev/null
# main과의 차이
git log main..HEAD --oneline 2>/dev/null
```
**사용자 확인** (AskUserQuestion):
- **질문**: "MR 타겟 브랜치를 선택하세요"
- 옵션 1: develop (추천, N건 커밋 차이)
- 옵션 2: main (N건 커밋 차이)
- 옵션 3: 취소
인자($ARGUMENTS)로 브랜치가 지정되었으면 확인 없이 바로 진행.
### 4. MR 정보 구성
```bash
# 커밋 목록
git log {target}..HEAD --oneline
# 변경 파일 통계
git diff {target}..HEAD --stat
```
- **제목**: 커밋이 1개면 커밋 메시지 사용, 여러 개면 브랜치명에서 추출
- `feature/ISSUE-42-user-login``feat: ISSUE-42 user-login`
- `bugfix/fix-timeout``fix: fix-timeout`
- **본문**:
```markdown
## 변경 사항
- (커밋 목록 기반 자동 생성)
## 관련 이슈
- closes #이슈번호 (브랜치명에서 추출, 없으면 생략)
## 테스트
- [ ] 빌드 성공 확인
- [ ] 기존 테스트 통과
```
### 5. Gitea API로 MR 생성
```bash
# remote URL에서 Gitea 호스트, owner, repo 파싱
# 예: https://gitea.gc-si.dev/gc/my-project.git → host=gitea.gc-si.dev, owner=gc, repo=my-project
curl -X POST "https://{host}/api/v1/repos/{owner}/{repo}/pulls" \
-H "Authorization: token ${GITEA_TOKEN}" \
-H "Content-Type: application/json" \
-d '{
"title": "MR 제목",
"body": "MR 본문",
"head": "현재브랜치",
"base": "타겟브랜치"
}'
```
### 6. 결과 출력
```
✅ MR 생성 완료
브랜치: feature/my-branch → develop
MR: https://gitea.gc-si.dev/gc/my-project/pulls/42
커밋: 3건, 파일: 5개 변경
다음 단계: 리뷰어 지정 → 승인 대기 → 머지
```
## 필요 환경변수
- `GITEA_TOKEN`: Gitea API 접근 토큰
- 없으면: "Gitea 토큰이 필요합니다. Settings → Applications에서 생성하세요" 안내
## 기존 /create-mr과의 차이
- `/mr`: 커밋+푸시 포함, 빠른 실행 (일상적 사용)
- `/create-mr`: MR 생성만, 세부 옵션 지원 (상세 제어)

파일 보기

@ -0,0 +1,92 @@
---
name: push
description: 변경 사항을 확인하고 커밋 + 푸시합니다
user-invocable: true
argument-hint: "[commit-message] (생략 시 자동 생성)"
allowed-tools: "Bash, Read, Grep"
---
현재 브랜치의 변경 사항을 확인하고, 사용자 승인 후 커밋 + 푸시합니다.
커밋 메시지 인자: $ARGUMENTS (생략 시 변경 내용 기반 자동 생성)
## 수행 단계
### 1. 현재 상태 수집
```bash
# 현재 브랜치
git branch --show-current
# 커밋되지 않은 변경 사항
git status --short
# 변경 통계
git diff --stat
git diff --cached --stat
```
### 2. 변경 범위 표시
사용자에게 다음 정보를 **표 형태**로 요약하여 보여준다:
- 현재 브랜치명
- 변경된 파일 목록 (추가/수정/삭제 구분)
- staged vs unstaged 구분
- 변경 라인 수 요약
변경 사항이 없으면 "커밋할 변경 사항이 없습니다" 출력 후 종료.
### 3. 커밋 메시지 결정
**인자가 있는 경우** ($ARGUMENTS가 비어있지 않으면):
- 전달받은 메시지를 커밋 메시지로 사용
- Conventional Commits 형식인지 검증 (아니면 자동 보정 제안)
**인자가 없는 경우**:
- 변경 내용을 분석하여 Conventional Commits 형식 메시지 자동 생성
- 형식: `type(scope): 한국어 설명`
- type 판단 기준:
- 새 파일 추가 → `feat`
- 기존 파일 수정 → `fix` 또는 `refactor`
- 테스트 파일 → `test`
- 설정/빌드 파일 → `chore`
- 문서 파일 → `docs`
### 4. 사용자 확인
AskUserQuestion으로 다음을 확인:
**질문**: "다음 내용으로 커밋하시겠습니까?"
- 옵션 1: 제안된 메시지로 커밋 (추천)
- 옵션 2: 메시지 수정 (Other 입력)
- 옵션 3: 취소
### 5. 커밋 + 푸시 실행
사용자가 수락하면:
```bash
# 모든 변경 사항 스테이징 (untracked 포함)
# 단, .env, secrets/ 등 민감 파일은 제외
git add -A
# 커밋 (.githooks/commit-msg가 형식 검증)
git commit -m "커밋메시지"
# 푸시 (리모트 트래킹 없으면 -u 추가)
git push origin $(git branch --show-current)
```
**주의사항**:
- `git add` 전에 `.env`, `*.key`, `secrets/` 등 민감 파일이 포함되어 있으면 경고
- pre-commit hook 실패 시 에러 메시지 표시 후 수동 해결 안내
- 리모트에 브랜치가 없으면 `git push -u origin {branch}` 사용
### 6. 결과 출력
```
✅ 푸시 완료
브랜치: feature/my-branch
커밋: abc1234 feat(auth): 로그인 검증 로직 추가
변경: 3 files changed, 45 insertions(+), 12 deletions(-)
```

파일 보기

@ -0,0 +1,134 @@
---
name: release
description: develop에서 main으로 릴리즈 MR을 생성합니다
user-invocable: true
argument-hint: ""
allowed-tools: "Bash, Read, Grep"
---
develop 브랜치와 원격 동기화를 확인하고, develop → main 릴리즈 MR을 생성합니다.
## 수행 단계
### 1. 사전 검증
```bash
# Gitea remote URL에서 owner/repo 추출
REMOTE_URL=$(git remote get-url origin)
# GITEA_TOKEN 확인
echo $GITEA_TOKEN
```
- GITEA_TOKEN 환경변수 확인 (없으면 설정 안내 후 종료)
- 커밋되지 않은 변경 사항이 있으면 경고 ("먼저 /push로 커밋하세요")
### 2. develop 브랜치 동기화 확인
```bash
# 최신 원격 상태 가져오기
git fetch origin
# 로컬 develop과 origin/develop 비교
LOCAL=$(git rev-parse develop 2>/dev/null)
REMOTE=$(git rev-parse origin/develop 2>/dev/null)
BASE=$(git merge-base develop origin/develop 2>/dev/null)
```
**동기화 상태 판단:**
| 상태 | 조건 | 행동 |
|------|------|------|
| 동일 | LOCAL == REMOTE | 바로 MR 생성 진행 |
| 로컬 뒤처짐 | LOCAL == BASE, LOCAL != REMOTE | "origin/develop에 새 커밋이 있습니다. `git pull origin develop` 후 다시 시도하세요" 안내 |
| 로컬 앞섬 | REMOTE == BASE, LOCAL != REMOTE | "로컬에 push되지 않은 커밋이 있습니다. `git push origin develop` 먼저 실행하시겠습니까?" 확인 |
| 분기됨 | 그 외 | "로컬과 원격 develop이 분기되었습니다. 수동으로 해결해주세요" 경고 후 종료 |
**로컬 앞섬 상태에서 사용자가 push 수락하면:**
```bash
git push origin develop
```
### 3. develop → main 차이 분석
```bash
# main 대비 develop의 새 커밋
git log main..origin/develop --oneline
# 변경 파일 통계
git diff main..origin/develop --stat
# 커밋 수
git rev-list --count main..origin/develop
```
차이가 없으면 "develop과 main이 동일합니다. 릴리즈할 변경이 없습니다" 출력 후 종료.
### 4. MR 정보 구성 + 사용자 확인
**제목 자동 생성:**
```
release: YYYY-MM-DD (N건 커밋)
```
**본문 자동 생성:**
```markdown
## 릴리즈 내용
- (develop→main 커밋 목록, Conventional Commits type별 그룹핑)
### 새 기능 (feat)
- feat(auth): 로그인 검증 로직 추가
- feat(batch): 배치 스케줄러 개선
### 버그 수정 (fix)
- fix(api): 타임아웃 처리 수정
### 기타
- chore: 의존성 업데이트
## 변경 파일
- N files changed, +M insertions, -K deletions
## 테스트
- [ ] develop 브랜치 빌드 성공 확인
- [ ] 주요 기능 동작 확인
```
**사용자 확인** (AskUserQuestion):
- **질문**: "다음 내용으로 릴리즈 MR을 생성하시겠습니까?"
- 옵션 1: 생성 (추천)
- 옵션 2: 제목/본문 수정 (Other 입력)
- 옵션 3: 취소
### 5. Gitea API로 릴리즈 MR 생성
```bash
curl -X POST "https://{host}/api/v1/repos/{owner}/{repo}/pulls" \
-H "Authorization: token ${GITEA_TOKEN}" \
-H "Content-Type: application/json" \
-d '{
"title": "release: 2026-02-19 (12건 커밋)",
"body": "릴리즈 본문",
"head": "develop",
"base": "main",
"labels": []
}'
```
### 6. 결과 출력
```
✅ 릴리즈 MR 생성 완료
브랜치: develop → main
MR: https://gitea.gc-si.dev/gc/my-project/pulls/50
커밋: 12건, 파일: 28개 변경
다음 단계:
1. 리뷰어 지정 (main 브랜치는 1명 이상 리뷰 필수)
2. 승인 후 머지
3. CI/CD 자동 배포 확인 (설정된 경우)
```
## 필요 환경변수
- `GITEA_TOKEN`: Gitea API 접근 토큰

파일 보기

@ -1,165 +1,98 @@
---
name: sync-team-workflow
description: 팀 글로벌 워크플로우를 현재 프로젝트에 동기화합니다
allowed-tools: "Bash, Read, Write, Edit, Glob, Grep"
---
팀 글로벌 워크플로우의 최신 파일을 서버에서 다운로드하여 로컬에 적용합니다.
호출 시 항상 서버 기준으로 전체 동기화합니다 (버전 비교 없음).
팀 글로벌 워크플로우의 최신 버전을 현재 프로젝트에 적용합니다.
## 수행 절차
### 1. 사전 조건 확인
`.claude/workflow-version.json` 존재 확인:
- 없으면 → "/init-project를 먼저 실행해주세요" 안내 후 종료
설정 읽기:
### 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")
PROJECT_TYPE=$(python3 -c "import json; print(json.load(open('.claude/workflow-version.json')).get('project_type', ''))" 2>/dev/null || echo "")
curl -sf "${GITEA_URL}/gc/template-common/raw/branch/develop/workflow-version.json"
```
프로젝트 타입이 비어있으면 자동 감지:
1. `pom.xml` → java-maven
2. `build.gradle` / `build.gradle.kts` → java-gradle
3. `package.json` + `tsconfig.json` → react-ts
4. 감지 실패 → 사용자에게 선택 요청
### 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** 사용:
⚠️ 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-${PROJECT_TYPE}/raw/branch/develop/<파일경로>
# 타입별 파일: ${GITEA_URL}/gc/template-<타입>/raw/branch/develop/<파일경로>
# 예시:
curl -sf "${GITEA_URL}/gc/template-common/raw/branch/develop/.claude/rules/team-policy.md"
curl -sf "${GITEA_URL}/gc/template-react-ts/raw/branch/develop/.editorconfig"
```
### 2. 디렉토리 준비
### 4. 파일 다운로드 및 적용
위의 URL 패턴으로 해당 타입 + common 템플릿 파일 다운로드:
필요한 디렉토리가 없으면 생성:
```bash
mkdir -p .claude/rules .claude/agents .claude/scripts
mkdir -p .claude/skills/push .claude/skills/mr .claude/skills/create-mr
mkdir -p .claude/skills/release .claude/skills/version .claude/skills/fix-issue
mkdir -p .githooks
```
### 3. 서버 파일 다운로드 + 적용
각 파일을 `curl -sf` 로 다운로드하여 프로젝트 루트의 동일 경로에 저장.
다운로드 실패한 파일은 경고 출력 후 건너뜀.
#### 3-1. template-common 파일 (덮어쓰기)
**규칙 파일**:
#### 4-1. 규칙 파일 (덮어쓰기)
팀 규칙은 로컬 수정 불가 — 항상 글로벌 최신으로 교체:
```
.claude/rules/team-policy.md
.claude/rules/git-workflow.md
.claude/rules/release-notes-guide.md
.claude/rules/subagent-policy.md
.claude/rules/code-style.md (타입별)
.claude/rules/naming.md (타입별)
.claude/rules/testing.md (타입별)
```
**에이전트 파일**:
```
.claude/agents/explorer.md
.claude/agents/implementer.md
.claude/agents/reviewer.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
**스킬 파일 (6종)**:
#### 4-3. 스킬 파일 (덮어쓰기)
```
.claude/skills/push/SKILL.md
.claude/skills/mr/SKILL.md
.claude/skills/create-mr/SKILL.md
.claude/skills/release/SKILL.md
.claude/skills/version/SKILL.md
.claude/skills/fix-issue/SKILL.md
.claude/skills/sync-team-workflow/SKILL.md
.claude/skills/init-project/SKILL.md
```
**Hook 스크립트**:
#### 4-4. Git Hooks (덮어쓰기 + 실행 권한)
```bash
chmod +x .githooks/*
```
#### 4-5. Hook 스크립트 갱신
init-project SKILL.md의 코드 블록에서 최신 스크립트를 추출하여 덮어쓰기:
```
.claude/scripts/on-pre-compact.sh
.claude/scripts/on-post-compact.sh
.claude/scripts/on-commit.sh
```
실행 권한 부여: `chmod +x .claude/scripts/*.sh`
**Git Hooks** (commit-msg, post-checkout은 항상 교체):
```
.githooks/commit-msg
.githooks/post-checkout
```
다운로드 예시:
```bash
curl -sf "${GITEA_URL}/gc/template-common/raw/branch/develop/.claude/rules/team-policy.md" -o ".claude/rules/team-policy.md"
```
#### 3-2. template-{type} 파일 (타입별 덮어쓰기)
```
.claude/rules/code-style.md
.claude/rules/naming.md
.claude/rules/testing.md
```
**pre-commit hook**:
`.claude/workflow-version.json``custom_pre_commit` 플래그 확인:
- `"custom_pre_commit": true` → pre-commit 건너뜀, "⚠️ pre-commit은 프로젝트 커스텀 유지" 로그
- 플래그 없거나 false → `.githooks/pre-commit` 교체
다운로드 예시:
```bash
curl -sf "${GITEA_URL}/gc/template-${PROJECT_TYPE}/raw/branch/develop/.claude/rules/code-style.md" -o ".claude/rules/code-style.md"
```
#### 3-3. 실행 권한 부여
```bash
chmod +x .githooks/* 2>/dev/null
chmod +x .claude/scripts/*.sh 2>/dev/null
```
### 4. settings.json 부분 머지
⚠️ settings.json은 **타입별 템플릿**에서 다운로드 (template-common에는 없음):
```bash
SERVER_SETTINGS=$(curl -sf "${GITEA_URL}/gc/template-${PROJECT_TYPE}/raw/branch/develop/.claude/settings.json")
```
다운로드한 최신 settings.json과 로컬 `.claude/settings.json`을 비교하여 부분 갱신:
- `env`: 서버 최신으로 교체
- `deny` 목록: 서버 최신으로 교체
- `allow` 목록: 기존 사용자 커스텀 유지 + 서버 기본값 병합
- `hooks`: 서버 최신으로 교체
### 5. workflow-version.json 갱신
서버의 최신 `workflow-version.json` 조회:
```bash
SERVER_VER=$(curl -sf "${GITEA_URL}/gc/template-common/raw/branch/develop/workflow-version.json")
SERVER_VERSION=$(echo "$SERVER_VER" | python3 -c "import sys,json; print(json.load(sys.stdin).get('version',''))")
```
`.claude/workflow-version.json` 업데이트:
### 5. 로컬 버전 업데이트
`.claude/workflow-version.json` 갱신:
```json
{
"applied_global_version": "<서버 version>",
"applied_date": "<현재날짜>",
"project_type": "<프로젝트타입>",
"gitea_url": "<GITEA_URL>"
"applied_global_version": "새버전",
"applied_date": "오늘날짜",
"project_type": "감지된타입",
"gitea_url": "https://gitea.gc-si.dev"
}
```
기존 필드(`custom_pre_commit` 등)는 보존.
### 6. 변경 보고
- 다운로드/갱신된 파일 목록 출력
- 서버 `workflow-version.json``changes` 중 최신 항목 표시
- 결과 형태:
```
✅ 팀 워크플로우 동기화 완료
버전: v1.6.0
갱신 파일: 22개 (rules 7, agents 3, skills 6, scripts 3, hooks 3)
settings.json: 부분 갱신 (env, deny, hooks)
```
## 필요 환경변수
없음 (Gitea raw URL은 인증 불필요)
- `git diff`로 변경 내역 확인
- 업데이트된 파일 목록 출력
- 변경 로그(글로벌 workflow-version.json의 changes) 표시
- 필요한 추가 조치 안내 (빌드 확인, 의존성 업데이트 등)

파일 보기

@ -1,7 +1,6 @@
{
"applied_global_version": "1.6.1",
"applied_date": "2026-03-18",
"applied_global_version": "1.2.0",
"applied_date": "2026-02-15",
"project_type": "react-ts",
"gitea_url": "https://gitea.gc-si.dev",
"custom_pre_commit": true
"gitea_url": "https://gitea.gc-si.dev"
}

파일 보기

@ -20,9 +20,10 @@ 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}$'
# - scope: 괄호 제외 모든 문자 허용 — 한/영/숫자/특수문자 (선택)
# - subject: 1자 이상 (길이는 바이트 기반 별도 검증)
PATTERN='^(feat|fix|docs|style|refactor|test|chore|ci|perf)(\([^)]+\))?: .+$'
MAX_SUBJECT_BYTES=200 # UTF-8 한글(3byte) 허용: 72문자 ≈ 최대 216byte
FIRST_LINE=$(head -1 "$COMMIT_MSG_FILE")
@ -58,3 +59,13 @@ if ! echo "$FIRST_LINE" | grep -qE "$PATTERN"; then
echo ""
exit 1
fi
# 길이 검증 (바이트 기반 — UTF-8 한글 허용)
MSG_LEN=$(echo -n "$FIRST_LINE" | wc -c | tr -d ' ')
if [ "$MSG_LEN" -gt "$MAX_SUBJECT_BYTES" ]; then
echo ""
echo " ✗ 커밋 메시지가 너무 깁니다 (${MSG_LEN}바이트, 최대 ${MAX_SUBJECT_BYTES})"
echo " 현재 메시지: $FIRST_LINE"
echo ""
exit 1
fi

11
.gitignore vendored
파일 보기

@ -39,14 +39,3 @@ coverage/
!.claude/
.claude/settings.local.json
.claude/CLAUDE.local.md
# Team workflow (managed by /sync-team-workflow)
.claude/rules/
.claude/agents/
.claude/skills/push/
.claude/skills/mr/
.claude/skills/create-mr/
.claude/skills/release/
.claude/skills/version/
.claude/skills/fix-issue/
.claude/scripts/

파일 보기

@ -19,4 +19,3 @@
@import "./styles/components/weather.css";
@import "./styles/components/weather-overlay.css";
@import "./styles/components/announcement.css";
@import "./styles/components/vessel-select-modal.css";

파일 보기

@ -141,79 +141,6 @@
border-color: var(--accent);
}
/* ── ENC Settings additions ──────────────────────────────────────── */
.map-settings-panel .ms-title .ms-reset-btn {
float: right;
font-size: 9px;
padding: 1px 6px;
border-radius: 3px;
border: 1px solid var(--border);
background: var(--card);
color: var(--muted);
cursor: pointer;
}
.map-settings-panel .ms-title .ms-reset-btn:hover {
color: var(--text);
border-color: var(--accent);
}
.map-settings-panel .ms-toggle-all {
float: right;
}
.map-settings-panel .ms-toggle-grid {
display: flex;
flex-wrap: wrap;
gap: 2px 10px;
}
.map-settings-panel .ms-toggle-item {
display: flex;
align-items: center;
gap: 4px;
font-size: 10px;
color: var(--muted);
cursor: pointer;
}
.map-settings-panel .ms-toggle-item input[type="checkbox"] {
width: 12px;
height: 12px;
margin: 0;
cursor: pointer;
}
.map-settings-panel .ms-color-row {
display: flex;
align-items: center;
justify-content: space-between;
gap: 8px;
padding: 2px 0;
font-size: 10px;
color: var(--muted);
}
.map-settings-panel .ms-color-row input[type="color"] {
width: 28px;
height: 18px;
padding: 0;
border: 1px solid var(--border);
border-radius: 3px;
background: transparent;
cursor: pointer;
}
.map-settings-panel .ms-color-row input[type="color"]::-webkit-color-swatch-wrapper {
padding: 1px;
}
.map-settings-panel .ms-color-row input[type="color"]::-webkit-color-swatch {
border: none;
border-radius: 2px;
}
/* ── Depth Legend ──────────────────────────────────────────────────── */
.depth-legend {

파일 보기

@ -1,152 +0,0 @@
/* ── Vessel select modal ─────────────────────────────────────────── */
.vessel-select-modal {
position: fixed;
inset: 0;
z-index: 1050;
display: flex;
align-items: center;
justify-content: center;
background: rgba(0, 0, 0, 0.5);
backdrop-filter: blur(4px);
}
.vessel-select-modal__content {
background: rgba(15, 23, 42, 0.96);
backdrop-filter: blur(12px);
border: 1px solid rgba(148, 163, 184, 0.25);
border-radius: 12px;
color: #e2e8f0;
width: 95vw;
max-width: 720px;
max-height: 85vh;
display: flex;
flex-direction: column;
box-shadow: 0 16px 48px rgba(2, 6, 23, 0.6);
overflow: hidden;
}
.vessel-select-modal__header {
display: flex;
align-items: center;
justify-content: space-between;
padding: 12px 16px;
border-bottom: 1px solid rgba(148, 163, 184, 0.15);
font-size: 14px;
flex-shrink: 0;
}
.vessel-select-modal__close {
background: none;
border: none;
color: #94a3b8;
font-size: 18px;
cursor: pointer;
padding: 2px 6px;
line-height: 1;
}
.vessel-select-modal__close:hover {
color: #e2e8f0;
}
.vessel-select-modal__back {
background: transparent;
border: none;
color: #94a3b8;
cursor: pointer;
font-size: 16px;
padding: 2px 6px;
line-height: 1;
}
.vessel-select-modal__back:hover {
color: #e2e8f0;
}
.vessel-select-modal__filters {
display: flex;
flex-wrap: wrap;
align-items: center;
gap: 6px;
padding: 10px 16px;
border-bottom: 1px solid rgba(148, 163, 184, 0.1);
flex-shrink: 0;
}
.vessel-select-modal__search {
padding: 8px 16px;
flex-shrink: 0;
}
.vessel-select-modal__grid {
flex: 1;
min-height: 0;
overflow-y: auto;
padding: 0 16px;
}
.vessel-select-modal__grid::-webkit-scrollbar {
width: 4px;
}
.vessel-select-modal__grid::-webkit-scrollbar-track {
background: transparent;
}
.vessel-select-modal__grid::-webkit-scrollbar-thumb {
background: rgba(148, 163, 184, 0.3);
border-radius: 2px;
}
.vessel-select-modal__footer {
display: flex;
align-items: center;
justify-content: space-between;
gap: 12px;
padding: 10px 16px;
border-top: 1px solid rgba(148, 163, 184, 0.15);
flex-shrink: 0;
font-size: 12px;
}
.vessel-select-modal__body {
padding: 16px;
flex: 1;
overflow-y: auto;
}
.vessel-select-modal__chips {
display: flex;
flex-wrap: wrap;
gap: 6px;
margin-top: 8px;
}
.vessel-select-modal__chip {
display: inline-flex;
align-items: center;
gap: 4px;
padding: 3px 8px;
background: rgba(148, 163, 184, 0.12);
border-radius: 999px;
font-size: 11px;
color: #cbd5e1;
}
.vessel-select-modal__presets {
display: flex;
gap: 8px;
margin-top: 16px;
}
.vessel-select-modal input[type='datetime-local'] {
flex: 1;
font-size: 12px;
padding: 6px 10px;
border-radius: 6px;
border: 1px solid rgba(148, 163, 184, 0.35);
background: rgba(30, 41, 59, 0.8);
color: #e2e8f0;
color-scheme: dark;
}

파일 보기

@ -31,14 +31,14 @@ export function buildLegacyVesselIndex(vessels: LegacyVesselInfo[]): LegacyVesse
if (score(v) > score(prev)) byName.set(k, v);
}
if (typeof v.mmsi === 'number' && Number.isFinite(v.mmsi)) {
const prev = byMmsi.get(v.mmsi);
if (!prev || score(v) > score(prev)) byMmsi.set(v.mmsi, v);
}
for (const m of v.mmsiList || []) {
if (!Number.isFinite(m)) continue;
if (byMmsi.has(m)) continue;
byMmsi.set(m, v);
const prev = byMmsi.get(m);
if (!prev) {
byMmsi.set(m, v);
continue;
}
if (score(v) > score(prev)) byMmsi.set(m, v);
}
}
@ -57,6 +57,19 @@ export function matchLegacyVessel(t: LegacyMatchable, idx: LegacyVesselIndex): L
const hit = idx.byMmsi.get(mmsi);
if (hit) return hit;
}
const nameKey = t.name ? normalizeShipName(t.name) : "";
if (nameKey) {
const hit = idx.byName.get(nameKey);
if (hit) return hit;
}
const csKey = t.callsign ? normalizeShipName(t.callsign) : "";
if (csKey) {
const hit = idx.byName.get(csKey);
if (hit) return hit;
}
return null;
}

파일 보기

@ -11,7 +11,6 @@ export type LegacyVesselInfo = {
shipCode: string; // PT | PT-S | GN | OT | PS | FC | ...
ton: number | null;
callSign: string;
mmsi: number | null;
mmsiList: number[];
workSeaArea: string;
workTerm1: string;

파일 보기

@ -28,33 +28,6 @@ export const ANNOUNCEMENTS: Announcement[] = [
},
],
},
{
id: 2,
title: 'Wing Fleet Dashboard 업데이트',
date: '2026-03-08',
items: [
{
icon: '🎯',
title: '대상선박 다중항적 조회',
description: '상단 "대상 선박 선택" 버튼으로 최대 20척을 한번에 선택하여 항적을 조회할 수 있습니다. 업종·상태 필터, 검색, 드래그 범위 선택을 지원합니다.',
},
{
icon: '📅',
title: '조회 기간 프리셋',
description: '7일·14일·21일·28일 프리셋 버튼으로 기간을 빠르게 설정할 수 있습니다. 최대 조회 범위는 28일이며 초과 시 자동 조정됩니다.',
},
{
icon: '🔄',
title: '항적 재조회 및 선박 목록 토글',
description: '리플레이 패널에서 기간을 수정하여 재조회하거나 CSV로 내보낼 수 있습니다. "선박 목록" 버튼으로 선택 화면을 열고 닫을 수 있습니다.',
},
{
icon: '🔔',
title: '경고 표시 개선',
description: '실시간 경고 효과가 테두리 링 형태로 변경되어 선박 아이콘과의 가독성이 향상되었습니다.',
},
],
},
];
/** 현재 최신 공지 ID */

파일 보기

@ -1,43 +0,0 @@
import { useEffect, useRef, type MutableRefObject } from 'react';
import type maplibregl from 'maplibre-gl';
import { applyEncVisibility, applyEncColors } from '../lib/encSettings';
import type { EncMapSettings } from '../model/types';
import type { BaseMapId } from '../../../widgets/map3d/types';
/**
* Applies ENC map settings changes at runtime (no style reload).
*/
export function useEncMapSettings(
mapRef: MutableRefObject<maplibregl.Map | null>,
baseMap: BaseMapId,
settings: EncMapSettings,
) {
const prevRef = useRef<EncMapSettings>(settings);
useEffect(() => {
if (baseMap !== 'enc') return;
const map = mapRef.current;
if (!map) return;
const prev = prevRef.current;
prevRef.current = settings;
const toggleKeys = [
'showBuoys', 'showBeacons', 'showLights', 'showDangers', 'showLandmarks',
'showSoundings', 'showPilot', 'showAnchorage', 'showRestricted',
'showDredged', 'showTSS', 'showContours',
] as const;
if (toggleKeys.some((k) => prev[k] !== settings[k])) {
applyEncVisibility(map, settings);
}
const colorKeys = [
'landColor', 'coastlineColor', 'backgroundColor',
'depthDrying', 'depthVeryShallow', 'depthSafetyZone', 'depthMedium', 'depthDeep',
] as const;
if (colorKeys.some((k) => prev[k] !== settings[k])) {
applyEncColors(map, settings);
}
}, [baseMap, settings]);
}

파일 보기

@ -1,6 +0,0 @@
export type { EncMapSettings } from './model/types';
export { DEFAULT_ENC_MAP_SETTINGS } from './model/types';
export { fetchEncStyle } from './lib/encStyle';
export { applyEncVisibility, applyEncColors } from './lib/encSettings';
export { useEncMapSettings } from './hooks/useEncMapSettings';
export { EncMapSettingsPanel } from './ui/EncMapSettingsPanel';

파일 보기

@ -1,61 +0,0 @@
import type maplibregl from 'maplibre-gl';
import type { EncMapSettings } from '../model/types';
import { ENC_LAYER_CATEGORIES, ENC_COLOR_TARGETS, ENC_DEPTH_COLOR_TARGETS } from '../model/types';
/**
* Apply symbol category visibility toggles at runtime.
*/
export function applyEncVisibility(map: maplibregl.Map, settings: EncMapSettings): void {
for (const [key, layerIds] of Object.entries(ENC_LAYER_CATEGORIES)) {
const visible = settings[key as keyof EncMapSettings] as boolean;
const vis = visible ? 'visible' : 'none';
for (const layerId of layerIds) {
try {
if (map.getLayer(layerId)) {
map.setLayoutProperty(layerId, 'visibility', vis);
}
} catch {
// layer may not exist
}
}
}
}
/**
* Apply runtime color changes to area/line layers.
*/
export function applyEncColors(map: maplibregl.Map, settings: EncMapSettings): void {
// 육지/해안선
for (const [layerId, prop, key] of ENC_COLOR_TARGETS) {
try {
if (map.getLayer(layerId)) {
map.setPaintProperty(layerId, prop, settings[key] as string);
}
} catch {
// ignore
}
}
// 배경색
try {
if (map.getLayer('background')) {
map.setPaintProperty('background', 'background-color', settings.backgroundColor);
}
} catch {
// ignore
}
// 수심별 색상
for (const { key, layerIds } of ENC_DEPTH_COLOR_TARGETS) {
const color = settings[key] as string;
for (const layerId of layerIds) {
try {
if (map.getLayer(layerId)) {
map.setPaintProperty(layerId, 'fill-color', color);
}
} catch {
// ignore
}
}
}
}

파일 보기

@ -1,28 +0,0 @@
import type { StyleSpecification } from 'maplibre-gl';
const NAUTICAL_STYLE_URL = 'https://tiles.gcnautical.com/styles/nautical.json';
/** Fonts available on the tile server */
const SERVER_FONTS = ['Noto Sans CJK KR Regular', 'Noto Sans Regular'];
/**
* Fetch the nautical chart style from gcnautical tile server.
* Patches text-font arrays to use only server-supported fonts (avoids 404 on composite fontstack).
*/
export async function fetchEncStyle(signal: AbortSignal): Promise<StyleSpecification> {
const res = await fetch(NAUTICAL_STYLE_URL, { signal });
if (!res.ok) throw new Error(`ENC style fetch failed: ${res.status}`);
const style = (await res.json()) as StyleSpecification;
// Patch text-font to avoid composite fontstack 404 errors
for (const layer of style.layers) {
const layout = (layer as { layout?: Record<string, unknown> }).layout;
if (!layout) continue;
const tf = layout['text-font'];
if (Array.isArray(tf) && tf.every((v) => typeof v === 'string')) {
layout['text-font'] = SERVER_FONTS;
}
}
return style;
}

파일 보기

@ -1,98 +0,0 @@
export interface EncDepthColor {
label: string;
layerIds: string[];
color: string;
}
export interface EncMapSettings {
// 심볼 카테고리별 표시 토글
showBuoys: boolean;
showBeacons: boolean;
showLights: boolean;
showDangers: boolean;
showLandmarks: boolean;
showSoundings: boolean;
showPilot: boolean;
showAnchorage: boolean;
showRestricted: boolean;
showDredged: boolean;
showTSS: boolean;
showContours: boolean;
// 영역 색상 (nautical.json 기본값 기준)
landColor: string;
coastlineColor: string;
backgroundColor: string;
// 수심별 색상
depthDrying: string;
depthVeryShallow: string;
depthSafetyZone: string;
depthMedium: string;
depthDeep: string;
}
/** nautical.json 기본 색상 기준 */
export const DEFAULT_ENC_MAP_SETTINGS: EncMapSettings = {
showBuoys: true,
showBeacons: true,
showLights: true,
showDangers: true,
showLandmarks: true,
showSoundings: true,
showPilot: true,
showAnchorage: true,
showRestricted: true,
showDredged: true,
showTSS: true,
showContours: true,
landColor: '#BFBE8D',
coastlineColor: '#4C5B62',
backgroundColor: '#93AEBB',
depthDrying: '#58AF99',
depthVeryShallow: '#61B7FF',
depthSafetyZone: '#82CAFF',
depthMedium: '#A7D9FA',
depthDeep: '#C9EDFD',
};
/**
* nautical.json ID .
* 49 12 .
*/
export const ENC_LAYER_CATEGORIES: Record<string, string[]> = {
showBuoys: ['boylat', 'boycar', 'boyisd', 'boysaw', 'boyspp'],
showBeacons: ['lndmrk'],
showLights: ['lights', 'lights-catlit'],
showDangers: ['uwtroc', 'obstrn', 'wrecks'],
showLandmarks: ['lndmrk'],
showSoundings: ['soundg', 'soundg-critical'],
showPilot: ['pilbop'],
showAnchorage: ['achare', 'achare-outline'],
showRestricted: ['resare-outline', 'resare-symbol', 'mipare'],
showDredged: [
'drgare-drying', 'drgare-very-shallow', 'drgare-safety-zone',
'drgare-medium', 'drgare-deep', 'drgare-pattern', 'drgare-outline', 'drgare-symbol',
],
showTSS: ['tsslpt', 'tsslpt-outline'],
showContours: ['depcnt', 'depare-safety-edge', 'depare-safety-edge-label'],
};
/** 영역 색상 → 레이어 ID + paint 속성 매핑 */
export const ENC_COLOR_TARGETS: [layerId: string, prop: string, settingsKey: keyof EncMapSettings][] = [
['lndare', 'fill-color', 'landColor'],
['globe-lndare', 'fill-color', 'landColor'],
['coalne', 'line-color', 'coastlineColor'],
['globe-coalne', 'line-color', 'coastlineColor'],
];
/** 수심별 색상 → 레이어 ID 매핑 */
export const ENC_DEPTH_COLOR_TARGETS: { key: keyof EncMapSettings; label: string; layerIds: string[] }[] = [
{ key: 'depthDrying', label: '건출 (< 0m)', layerIds: ['depare-drying', 'drgare-drying'] },
{ key: 'depthVeryShallow', label: '극천 (0~2m)', layerIds: ['depare-very-shallow', 'drgare-very-shallow'] },
{ key: 'depthSafetyZone', label: '안전수심 (2~30m)', layerIds: ['depare-safety-zone', 'drgare-safety-zone'] },
{ key: 'depthMedium', label: '중간 (30m~)', layerIds: ['depare-medium', 'drgare-medium'] },
{ key: 'depthDeep', label: '심해', layerIds: ['depare-deep', 'drgare-deep'] },
];

파일 보기

@ -1,138 +0,0 @@
import { useState } from 'react';
import type { EncMapSettings } from '../model/types';
import { DEFAULT_ENC_MAP_SETTINGS, ENC_DEPTH_COLOR_TARGETS } from '../model/types';
interface EncMapSettingsPanelProps {
value: EncMapSettings;
onChange: (next: EncMapSettings) => void;
}
const SYMBOL_TOGGLES: { key: keyof EncMapSettings; label: string }[] = [
{ key: 'showBuoys', label: '부표' },
{ key: 'showBeacons', label: '비콘' },
{ key: 'showLights', label: '등대' },
{ key: 'showDangers', label: '위험물' },
{ key: 'showLandmarks', label: '랜드마크' },
{ key: 'showSoundings', label: '수심' },
{ key: 'showPilot', label: '도선소' },
{ key: 'showAnchorage', label: '정박지' },
{ key: 'showRestricted', label: '제한구역' },
{ key: 'showDredged', label: '준설구역' },
{ key: 'showTSS', label: '통항분리대' },
{ key: 'showContours', label: '등심선' },
];
const AREA_COLOR_INPUTS: { key: keyof EncMapSettings; label: string }[] = [
{ key: 'backgroundColor', label: '바다 배경' },
{ key: 'landColor', label: '육지' },
{ key: 'coastlineColor', label: '해안선' },
];
export function EncMapSettingsPanel({ value, onChange }: EncMapSettingsPanelProps) {
const [open, setOpen] = useState(false);
const update = <K extends keyof EncMapSettings>(key: K, val: EncMapSettings[K]) => {
onChange({ ...value, [key]: val });
};
const isDefault = JSON.stringify(value) === JSON.stringify(DEFAULT_ENC_MAP_SETTINGS);
const allChecked = SYMBOL_TOGGLES.every(({ key }) => value[key] as boolean);
const toggleAll = (checked: boolean) => {
const next = { ...value };
for (const { key } of SYMBOL_TOGGLES) {
(next as Record<string, unknown>)[key] = checked;
}
onChange(next);
};
return (
<>
<button
className={`map-settings-gear${open ? ' open' : ''}`}
onClick={() => setOpen((p) => !p)}
title="ENC 설정"
type="button"
>
</button>
{open && (
<div className="map-settings-panel">
<div className="ms-title">
ENC
{!isDefault && (
<button
className="ms-reset-btn"
onClick={() => onChange(DEFAULT_ENC_MAP_SETTINGS)}
title="기본값 복원"
type="button"
>
</button>
)}
</div>
{/* ── 레이어 토글 ── */}
<div className="ms-section">
<div className="ms-label">
<label className="ms-toggle-item ms-toggle-all">
<input
type="checkbox"
checked={allChecked}
onChange={(e) => toggleAll(e.target.checked)}
/>
<span></span>
</label>
</div>
<div className="ms-toggle-grid">
{SYMBOL_TOGGLES.map(({ key, label }) => (
<label key={key} className="ms-toggle-item">
<input
type="checkbox"
checked={value[key] as boolean}
onChange={(e) => update(key, e.target.checked as never)}
/>
<span>{label}</span>
</label>
))}
</div>
</div>
{/* ── 영역 색상 ── */}
<div className="ms-section">
<div className="ms-label"> </div>
{AREA_COLOR_INPUTS.map(({ key, label }) => (
<div key={key} className="ms-color-row">
<span>{label}</span>
<input
type="color"
value={value[key] as string}
onChange={(e) => update(key, e.target.value as never)}
title={label}
/>
</div>
))}
</div>
{/* ── 수심별 색상 ── */}
<div className="ms-section">
<div className="ms-label"> </div>
{ENC_DEPTH_COLOR_TARGETS.map(({ key, label }) => (
<div key={key} className="ms-color-row">
<span>{label}</span>
<input
type="color"
value={value[key] as string}
onChange={(e) => update(key, e.target.value as never)}
title={label}
/>
</div>
))}
</div>
</div>
)}
</>
);
}

파일 보기

@ -58,7 +58,6 @@ function makeLegacy(
shipNameCn: null,
ton: 100,
callSign: '',
mmsi: o.mmsiList[0] ?? null,
workSeaArea: '서해',
workTerm1: '2025-01-01',
workTerm2: '2025-12-31',

파일 보기

@ -12,46 +12,14 @@ const OCEAN_DEPTH_FONT_SIZE: Record<OceanDepthLabelSize, unknown[]> = {
large: ['interpolate', ['linear'], ['zoom'], 5, 12, 8, 15, 11, 18],
};
/* ── Original paint cache (Ocean 네이티브 색상 복원용) ─────────── */
const originalDepthPaint = new Map<string, { fillColor: unknown; fillOpacity: unknown }>();
function captureOriginalDepthPaint(map: maplibregl.Map, layers: string[]) {
if (originalDepthPaint.size > 0) return; // 이미 캡처됨
for (const id of layers) {
if (!map.getLayer(id)) continue;
try {
originalDepthPaint.set(id, {
fillColor: map.getPaintProperty(id, 'fill-color'),
fillOpacity: map.getPaintProperty(id, 'fill-opacity'),
});
} catch { /* ignore */ }
}
}
function restoreOriginalDepthPaint(map: maplibregl.Map, layers: string[]) {
for (const id of layers) {
const orig = originalDepthPaint.get(id);
if (!orig || !map.getLayer(id)) continue;
try {
map.setPaintProperty(id, 'fill-color', orig.fillColor as never);
map.setPaintProperty(id, 'fill-opacity', orig.fillOpacity as never);
} catch { /* ignore */ }
}
}
/* ── Apply functions (Ocean 전용 — enhanced 코드와 공유 없음) ────── */
function applyOceanDepthColors(map: maplibregl.Map, layers: string[], stops: OceanDepthStop[], opacity: number) {
if (layers.length === 0) return;
const sorted = [...stops].sort((a, b) => a.depth - b.depth);
if (sorted.length < 2) {
// depthStops 비어있음 → Ocean 네이티브 색상 복원
restoreOriginalDepthPaint(map, layers);
return;
}
const depth = ['to-number', ['get', 'depth']];
const sorted = [...stops].sort((a, b) => a.depth - b.depth);
if (sorted.length < 2) return;
const expr: unknown[] = ['interpolate', ['linear'], depth];
for (const s of sorted) {
expr.push(s.depth, s.color);
@ -208,9 +176,6 @@ export function useOceanMapSettings(
const stop = onMapStyleReady(map, () => {
const oceanLayers = discoverOceanLayers(map);
// 커스텀 적용 전에 원본 paint 캡처 (최초 1회)
captureOriginalDepthPaint(map, oceanLayers.depthFill);
applyOceanDepthColors(map, oceanLayers.depthFill, s.depthStops, s.depthOpacity);
applyOceanContourStyle(map, oceanLayers.contourLine, s.contourVisible, s.contourColor, s.contourOpacity, s.contourWidth);
applyOceanDepthLabels(map, oceanLayers.depthLabel, s.depthLabelsVisible, s.depthLabelColor, s.depthLabelSize);

파일 보기

@ -1,5 +1,5 @@
export type { OceanMapSettings, OceanDepthStop, OceanLabelLanguage, OceanDepthLabelSize } from './model/types';
export { DEFAULT_OCEAN_MAP_SETTINGS, OCEAN_PRESET_DEPTH_STOPS } from './model/types';
export { DEFAULT_OCEAN_MAP_SETTINGS } from './model/types';
export { resolveOceanStyle } from './lib/resolveOceanStyle';
export { discoverOceanLayers } from './lib/oceanLayerIds';
export { useOceanMapSettings } from './hooks/useOceanMapSettings';

파일 보기

@ -38,33 +38,14 @@ export interface OceanMapSettings {
labelLanguage: OceanLabelLanguage;
}
/**
* Ocean (12).
* "커스텀" .
* Ocean .
*/
export const OCEAN_PRESET_DEPTH_STOPS: OceanDepthStop[] = [
{ depth: -11000, color: '#0a0e2a' },
{ depth: -8000, color: '#0c1836' },
{ depth: -6000, color: '#0e2444' },
{ depth: -4000, color: '#103252' },
{ depth: -2000, color: '#134060' },
{ depth: -1000, color: '#175070' },
{ depth: -200, color: '#1c6480' },
{ depth: -100, color: '#217890' },
{ depth: -50, color: '#288da0' },
{ depth: -20, color: '#30a2b0' },
{ depth: -10, color: '#3ab5be' },
{ depth: 0, color: '#48c8cc' },
];
/**
* Ocean .
* depthStops , .
* "초기화" .
* depthStops가 Ocean .
* depthStops에 .
*/
export const DEFAULT_OCEAN_MAP_SETTINGS: OceanMapSettings = {
depthStops: OCEAN_PRESET_DEPTH_STOPS,
// 빈 배열 = Ocean 스타일 네이티브 색상 사용 (커스텀 안 함)
depthStops: [],
depthOpacity: 1,
contourVisible: true,

파일 보기

@ -1,6 +1,6 @@
import { useState } from 'react';
import type { OceanMapSettings, OceanLabelLanguage, OceanDepthLabelSize, OceanDepthStop } from '../model/types';
import { DEFAULT_OCEAN_MAP_SETTINGS, OCEAN_PRESET_DEPTH_STOPS } from '../model/types';
import { DEFAULT_OCEAN_MAP_SETTINGS } from '../model/types';
interface OceanMapSettingsPanelProps {
value: OceanMapSettings;
@ -114,30 +114,12 @@ export function OceanMapSettingsPanel({ value, onChange }: OceanMapSettingsPanel
<div className="ms-section">
<div className="ms-label" style={{ display: 'flex', alignItems: 'center', justifyContent: 'space-between' }}>
<span style={{ display: 'flex', gap: 4 }}>
{value.depthStops.length > 0 && (
<span
className={`cursor-pointer rounded border px-1.5 py-px text-[8px] transition-all duration-150 select-none ${autoGradient ? 'border-wing-accent bg-wing-accent text-white' : 'border-wing-border bg-wing-card text-wing-muted'}`}
onClick={toggleAutoGradient}
title="최소/최대 색상 기준으로 중간 구간을 자동 보간합니다"
>
</span>
)}
<span
className={`cursor-pointer rounded border px-1.5 py-px text-[8px] transition-all duration-150 select-none ${value.depthStops.length > 0 ? 'border-wing-accent bg-wing-accent text-white' : 'border-wing-border bg-wing-card text-wing-muted'}`}
onClick={() => {
if (value.depthStops.length > 0) {
update('depthStops', []);
setAutoGradient(false);
} else {
update('depthStops', OCEAN_PRESET_DEPTH_STOPS);
}
}}
title={value.depthStops.length > 0 ? 'Ocean 스타일 네이티브 색상으로 복원' : '수심 구간별 색상을 직접 지정합니다'}
>
{value.depthStops.length > 0 ? '기본값' : '커스텀'}
</span>
<span
className={`ml-2 cursor-pointer rounded border px-1.5 py-px text-[8px] transition-all duration-150 select-none ${autoGradient ? 'border-wing-accent bg-wing-accent text-white' : 'border-wing-border bg-wing-card text-wing-muted'}`}
onClick={toggleAutoGradient}
title="최소/최대 색상 기준으로 중간 구간을 자동 보간합니다"
>
</span>
</div>
{value.depthStops.length === 0 ? (

파일 보기

@ -1,147 +0,0 @@
import { DISPLAY_TZ } from '../../../shared/lib/datetime';
import { haversineNm } from '../../../shared/lib/geo/haversineNm';
import type { MultiTrackQueryContext, ProcessedTrack, TrackQueryContext } from '../model/track.types';
const BOM = '\uFEFF';
function escCsv(value: string | number | undefined | null): string {
if (value == null) return '';
const s = String(value);
if (s.includes(',') || s.includes('"') || s.includes('\n')) {
return `"${s.replace(/"/g, '""')}"`;
}
return s;
}
function fmtTimestamp(ms: number): string {
if (!Number.isFinite(ms) || ms <= 0) return '';
return new Date(ms).toLocaleString('sv-SE', { timeZone: DISPLAY_TZ });
}
/** 두 포인트 간 거리(NM)·시간차(h)로 속력(knots) 계산 */
function calcSpeedKnots(track: ProcessedTrack, index: number): number {
if (index <= 0) return 0;
const [lon1, lat1] = track.geometry[index - 1];
const [lon2, lat2] = track.geometry[index];
const dtMs = track.timestampsMs[index] - track.timestampsMs[index - 1];
if (dtMs <= 0) return 0;
const distNm = haversineNm(lat1, lon1, lat2, lon2);
const hours = dtMs / 3_600_000;
return Math.round((distNm / hours) * 100) / 100;
}
/** 포인트별 1행: mmsi, longitude, latitude, timestamp, speedKnots */
export function buildDynamicCsv(tracks: ProcessedTrack[], ctx: TrackQueryContext | null, multiCtx?: MultiTrackQueryContext | null): string {
const header = ['mmsi', 'longitude', 'latitude', 'timestamp', 'timestampMs', 'speedKnots'];
const rows: string[] = [header.join(',')];
const mmsi = ctx?.mmsi ?? '';
for (const track of tracks) {
const trackMmsi = multiCtx ? track.targetId : (mmsi || track.targetId);
for (let i = 0; i < track.geometry.length; i++) {
rows.push(
[
escCsv(trackMmsi),
escCsv(track.geometry[i][0]),
escCsv(track.geometry[i][1]),
escCsv(fmtTimestamp(track.timestampsMs[i])),
escCsv(track.timestampsMs[i]),
escCsv(calcSpeedKnots(track, i)),
].join(','),
);
}
}
return BOM + rows.join('\n');
}
/** 선박별 1행: mmsi, 선명, 업종, 소유주, 선단, 허가번호 등 전체 메타 */
export function buildStaticCsv(tracks: ProcessedTrack[], ctx: TrackQueryContext | null, multiCtx?: MultiTrackQueryContext | null): string {
const header = [
'mmsi',
'shipName',
'vesselType',
'ownerCn',
'ownerRoman',
'permitNo',
'pairPermitNo',
'ton',
'callSign',
'workSeaArea',
'nationalCode',
'totalDistanceNm',
'avgSpeed',
'maxSpeed',
'pointCount',
'startTime',
'endTime',
'chnPrmShipName',
'chnPrmVesselType',
'chnPrmCallsign',
'chnPrmImo',
];
const rows: string[] = [header.join(',')];
// Build per-mmsi lookup for multi-vessel mode
const multiVesselMap = multiCtx
? new Map(multiCtx.vessels.map((v) => [String(v.mmsi), v]))
: null;
for (const track of tracks) {
const firstTs = track.timestampsMs[0] ?? 0;
const lastTs = track.timestampsMs[track.timestampsMs.length - 1] ?? 0;
const info = track.chnPrmShipInfo;
const mv = multiVesselMap?.get(track.targetId);
rows.push(
[
escCsv(mv?.mmsi ?? ctx?.mmsi ?? track.targetId),
escCsv(track.shipName),
escCsv(mv?.vesselType ?? ctx?.vesselType ?? ''),
escCsv(mv?.ownerCn ?? ctx?.ownerCn),
escCsv(mv?.ownerRoman ?? ctx?.ownerRoman),
escCsv(mv?.permitNo ?? ctx?.permitNo),
escCsv(mv?.pairPermitNo ?? ctx?.pairPermitNo),
escCsv(mv?.ton ?? ctx?.ton),
escCsv(mv?.callSign ?? ctx?.callSign),
escCsv(mv?.workSeaArea ?? ctx?.workSeaArea),
escCsv(track.nationalCode),
escCsv(track.stats.totalDistanceNm),
escCsv(track.stats.avgSpeed),
escCsv(track.stats.maxSpeed),
escCsv(track.stats.pointCount),
escCsv(fmtTimestamp(firstTs)),
escCsv(fmtTimestamp(lastTs)),
escCsv(info?.name),
escCsv(info?.vesselType),
escCsv(info?.callsign),
escCsv(info?.imo),
].join(','),
);
}
return BOM + rows.join('\n');
}
function downloadCsv(csvContent: string, filename: string): void {
const blob = new Blob([csvContent], { type: 'text/csv;charset=utf-8' });
const url = URL.createObjectURL(blob);
const a = document.createElement('a');
a.href = url;
a.download = filename;
document.body.appendChild(a);
a.click();
document.body.removeChild(a);
URL.revokeObjectURL(url);
}
export function exportTrackCsv(tracks: ProcessedTrack[], ctx: TrackQueryContext | null, multiCtx?: MultiTrackQueryContext | null): void {
const now = new Date();
const ts = now.toISOString().replace(/[:.]/g, '-').slice(0, 19);
downloadCsv(buildDynamicCsv(tracks, ctx, multiCtx), `track-points-${ts}.csv`);
setTimeout(() => {
downloadCsv(buildStaticCsv(tracks, ctx, multiCtx), `track-vessel-${ts}.csv`);
}, 100);
}

파일 보기

@ -7,13 +7,6 @@ export interface TrackStats {
pointCount: number;
}
export interface ChnPrmShipInfo {
name?: string;
vesselType?: string;
callsign?: string;
imo?: number;
}
export interface ProcessedTrack {
vesselId: string;
targetId: string;
@ -25,7 +18,6 @@ export interface ProcessedTrack {
timestampsMs: number[];
speeds: number[];
stats: TrackStats;
chnPrmShipInfo?: ChnPrmShipInfo;
}
export interface CurrentVesselPosition {
@ -46,43 +38,6 @@ export interface TrackQueryRequest {
minutes: number;
}
export interface TrackQueryContext {
mmsi: number;
startTimeIso: string;
endTimeIso: string;
shipNameHint?: string;
shipKindCodeHint?: string;
nationalCodeHint?: string;
isPermitted: boolean;
vesselType?: string;
ownerCn?: string | null;
ownerRoman?: string | null;
permitNo?: string;
pairPermitNo?: string | null;
ton?: number | null;
callSign?: string;
workSeaArea?: string;
}
export interface MultiTrackQueryContext {
vessels: Array<{
mmsi: number;
shipNameHint?: string;
isPermitted: boolean;
vesselType?: string;
ownerCn?: string | null;
ownerRoman?: string | null;
permitNo?: string;
pairPermitNo?: string | null;
ton?: number | null;
callSign?: string;
workSeaArea?: string;
shipCode?: string;
}>;
startTimeIso: string;
endTimeIso: string;
}
export interface ReplayStreamQueryRequest {
startTime: string;
endTime: string;

파일 보기

@ -9,16 +9,6 @@ type QueryTrackByMmsiParams = {
isPermitted?: boolean;
};
export type QueryTrackByDateRangeParams = {
mmsi: number;
startTimeIso: string;
endTimeIso: string;
shipNameHint?: string;
shipKindCodeHint?: string;
nationalCodeHint?: string;
isPermitted?: boolean;
};
type V2TrackResponse = {
vesselId?: string;
targetId?: string;
@ -110,26 +100,23 @@ function convertV2Tracks(rows: V2TrackResponse[]): ProcessedTrack[] {
maxSpeed: row.maxSpeed || 0,
pointCount: row.pointCount || geometry.length,
},
chnPrmShipInfo: row.chnPrmShipInfo ? { ...row.chnPrmShipInfo } : undefined,
});
}
return out;
}
async function fetchV2Tracks(
startTimeIso: string,
endTimeIso: string,
mmsis: number[],
hasPermitted: boolean,
): Promise<ProcessedTrack[]> {
async function queryV2Track(params: QueryTrackByMmsiParams): Promise<ProcessedTrack[]> {
const base = (import.meta.env.VITE_TRACK_V2_BASE_URL || '/signal-batch').trim();
const end = new Date();
const start = new Date(end.getTime() - params.minutes * 60_000);
const requestBody = {
startTime: startTimeIso,
endTime: endTimeIso,
vessels: mmsis.map(String),
includeChnPrmShip: hasPermitted,
startTime: start.toISOString(),
endTime: end.toISOString(),
vessels: [String(params.mmsi)],
includeChnPrmShip: params.isPermitted ?? false,
};
const endpoint = `${base.replace(/\/$/, '')}/api/v2/tracks/vessels`;
@ -154,22 +141,5 @@ async function fetchV2Tracks(
}
export async function queryTrackByMmsi(params: QueryTrackByMmsiParams): Promise<ProcessedTrack[]> {
const end = new Date();
const start = new Date(end.getTime() - params.minutes * 60_000);
return fetchV2Tracks(start.toISOString(), end.toISOString(), [params.mmsi], params.isPermitted ?? false);
}
export async function queryTrackByDateRange(params: QueryTrackByDateRangeParams): Promise<ProcessedTrack[]> {
return fetchV2Tracks(params.startTimeIso, params.endTimeIso, [params.mmsi], params.isPermitted ?? false);
}
export type QueryMultiTrackParams = {
mmsis: number[];
startTimeIso: string;
endTimeIso: string;
hasPermitted: boolean;
};
export async function queryMultiTrack(params: QueryMultiTrackParams): Promise<ProcessedTrack[]> {
return fetchV2Tracks(params.startTimeIso, params.endTimeIso, params.mmsis, params.hasPermitted);
return queryV2Track(params);
}

파일 보기

@ -1,7 +1,6 @@
import { create } from 'zustand';
import { getTracksTimeRange } from '../lib/adapters';
import type { MultiTrackQueryContext, ProcessedTrack, TrackQueryContext } from '../model/track.types';
import { queryMultiTrack, queryTrackByDateRange } from '../services/trackQueryService';
import type { ProcessedTrack } from '../model/track.types';
import { useTrackPlaybackStore } from './trackPlaybackStore';
export type TrackQueryStatus = 'idle' | 'loading' | 'ready' | 'error';
@ -15,20 +14,16 @@ interface TrackQueryState {
queryState: TrackQueryStatus;
renderEpoch: number;
lastQueryKey: string | null;
queryContext: TrackQueryContext | null;
multiQueryContext: MultiTrackQueryContext | null;
showPoints: boolean;
showVirtualShip: boolean;
showLabels: boolean;
showTrail: boolean;
hideLiveShips: boolean;
beginQuery: (queryKey: string, context?: TrackQueryContext) => void;
beginMultiQuery: (queryKey: string, ctx: MultiTrackQueryContext) => Promise<void>;
beginQuery: (queryKey: string) => void;
applyTracksSuccess: (tracks: ProcessedTrack[], queryKey?: string | null) => void;
applyQueryError: (error: string, queryKey?: string | null) => void;
closeQuery: () => void;
requery: (startTimeIso: string, endTimeIso: string) => Promise<void>;
setTracks: (tracks: ProcessedTrack[]) => void;
clearTracks: () => void;
@ -54,15 +49,13 @@ export const useTrackQueryStore = create<TrackQueryState>()((set, get) => ({
queryState: 'idle',
renderEpoch: 0,
lastQueryKey: null,
queryContext: null,
multiQueryContext: null,
showPoints: true,
showVirtualShip: true,
showLabels: true,
showTrail: true,
hideLiveShips: false,
beginQuery: (queryKey: string, context?: TrackQueryContext) => {
beginQuery: (queryKey: string) => {
useTrackPlaybackStore.getState().reset();
set((state) => ({
tracks: [],
@ -73,51 +66,14 @@ export const useTrackQueryStore = create<TrackQueryState>()((set, get) => ({
queryState: 'loading',
renderEpoch: state.renderEpoch + 1,
lastQueryKey: queryKey,
hideLiveShips: true,
queryContext: context ?? state.queryContext,
multiQueryContext: null,
hideLiveShips: false,
}));
},
beginMultiQuery: async (queryKey: string, ctx: MultiTrackQueryContext) => {
useTrackPlaybackStore.getState().reset();
set((state) => ({
tracks: [],
disabledVesselIds: new Set<string>(),
highlightedVesselId: null,
isLoading: true,
error: null,
queryState: 'loading',
renderEpoch: state.renderEpoch + 1,
lastQueryKey: queryKey,
hideLiveShips: true,
queryContext: null,
multiQueryContext: ctx,
}));
try {
const mmsis = ctx.vessels.map((v) => v.mmsi);
const hasPermitted = ctx.vessels.some((v) => v.isPermitted);
const tracks = await queryMultiTrack({
mmsis,
startTimeIso: ctx.startTimeIso,
endTimeIso: ctx.endTimeIso,
hasPermitted,
});
if (tracks.length > 0) {
get().applyTracksSuccess(tracks, queryKey);
} else {
get().applyQueryError('항적 데이터가 없습니다.', queryKey);
}
} catch (e) {
get().applyQueryError(e instanceof Error ? e.message : '항적 조회에 실패했습니다.', queryKey);
}
},
applyTracksSuccess: (tracks: ProcessedTrack[], queryKey?: string | null) => {
const currentQueryKey = get().lastQueryKey;
if (queryKey != null && queryKey !== currentQueryKey) {
// Ignore stale async responses from an older query.
return;
}
@ -157,6 +113,7 @@ export const useTrackQueryStore = create<TrackQueryState>()((set, get) => ({
applyQueryError: (error: string, queryKey?: string | null) => {
const currentQueryKey = get().lastQueryKey;
if (queryKey != null && queryKey !== currentQueryKey) {
// Ignore stale async errors from an older query.
return;
}
@ -185,51 +142,10 @@ export const useTrackQueryStore = create<TrackQueryState>()((set, get) => ({
queryState: 'idle',
renderEpoch: state.renderEpoch + 1,
lastQueryKey: null,
queryContext: null,
multiQueryContext: null,
hideLiveShips: false,
}));
},
requery: async (startTimeIso: string, endTimeIso: string) => {
const multiCtx = get().multiQueryContext;
if (multiCtx) {
const queryKey = `requery-multi:${Date.now()}`;
const updatedCtx: MultiTrackQueryContext = { ...multiCtx, startTimeIso, endTimeIso };
// Preserve multiQueryContext across beginMultiQuery
await get().beginMultiQuery(queryKey, updatedCtx);
return;
}
const ctx = get().queryContext;
if (!ctx) return;
const queryKey = `requery:${ctx.mmsi}:${Date.now()}`;
const updatedContext: TrackQueryContext = { ...ctx, startTimeIso, endTimeIso };
get().beginQuery(queryKey, updatedContext);
try {
const tracks = await queryTrackByDateRange({
mmsi: updatedContext.mmsi,
startTimeIso: updatedContext.startTimeIso,
endTimeIso: updatedContext.endTimeIso,
shipNameHint: updatedContext.shipNameHint,
shipKindCodeHint: updatedContext.shipKindCodeHint,
nationalCodeHint: updatedContext.nationalCodeHint,
isPermitted: updatedContext.isPermitted,
});
if (tracks.length > 0) {
get().applyTracksSuccess(tracks, queryKey);
} else {
get().applyQueryError('항적 데이터가 없습니다.', queryKey);
}
} catch (e) {
get().applyQueryError(e instanceof Error ? e.message : '항적 조회에 실패했습니다.', queryKey);
}
},
setTracks: (tracks: ProcessedTrack[]) => {
get().applyTracksSuccess(tracks, get().lastQueryKey);
},
@ -284,8 +200,6 @@ export const useTrackQueryStore = create<TrackQueryState>()((set, get) => ({
queryState: 'idle',
renderEpoch: state.renderEpoch + 1,
lastQueryKey: null,
queryContext: null,
multiQueryContext: null,
showPoints: true,
showVirtualShip: true,
showLabels: true,

파일 보기

@ -1,66 +0,0 @@
import { useCallback, useMemo } from 'react';
import { usePersistedState } from '../../../shared/hooks/usePersistedState';
import type { VesselGroup } from '../model/types';
import { MAX_VESSEL_GROUPS } from '../model/types';
export interface VesselGroupsState {
groups: VesselGroup[];
/** 동명 그룹 존재 시 갱신, 신규 시 추가. 10개 초과 시 경고 문자열 반환 */
saveGroup: (name: string, mmsis: number[]) => string | null;
deleteGroup: (id: string) => void;
applyGroup: (group: VesselGroup) => void;
}
export function useVesselGroups(
userId: number | null,
setMmsis: (mmsis: Set<number>) => void,
): VesselGroupsState {
const [rawGroups, setRawGroups] = usePersistedState<VesselGroup[]>(userId, 'vesselGroups', []);
const groups = useMemo(
() => [...rawGroups].sort((a, b) => b.updatedAt - a.updatedAt),
[rawGroups],
);
const saveGroup = useCallback(
(name: string, mmsis: number[]): string | null => {
const trimmed = name.trim();
if (!trimmed) return '그룹명을 입력해주세요';
let warning: string | null = null;
setRawGroups((prev) => {
const existing = prev.find((g) => g.name === trimmed);
if (existing) {
return prev.map((g) =>
g.id === existing.id ? { ...g, mmsis, updatedAt: Date.now() } : g,
);
}
if (prev.length >= MAX_VESSEL_GROUPS) {
warning = `최대 ${MAX_VESSEL_GROUPS}개까지 저장 가능합니다`;
return prev;
}
return [...prev, { id: Date.now().toString(36), name: trimmed, mmsis, updatedAt: Date.now() }];
});
return warning;
},
[setRawGroups],
);
const deleteGroup = useCallback(
(id: string) => {
setRawGroups((prev) => prev.filter((g) => g.id !== id));
},
[setRawGroups],
);
const applyGroup = useCallback(
(group: VesselGroup) => {
setMmsis(new Set(group.mmsis));
},
[setMmsis],
);
return { groups, saveGroup, deleteGroup, applyGroup };
}

파일 보기

@ -1,286 +0,0 @@
import { useCallback, useState } from 'react';
import type { DerivedLegacyVessel } from '../../legacyDashboard/model/types';
import type { MultiTrackQueryContext } from '../../trackReplay/model/track.types';
import { useTrackQueryStore } from '../../trackReplay/stores/trackQueryStore';
import { MAX_VESSEL_SELECT, MAX_QUERY_DAYS } from '../model/types';
import type { VesselGroup } from '../model/types';
import { useVesselGroups } from './useVesselGroups';
/** ms → datetime-local input value (KST = UTC+9) */
function toDateTimeLocalKST(ms: number): string {
const kstDate = new Date(ms + 9 * 3600_000);
return kstDate.toISOString().slice(0, 16);
}
/** datetime-local value (KST) → ISO string */
function fromDateTimeLocalKST(value: string): string {
return `${value}:00+09:00`;
}
const DEFAULT_DAYS = 7;
export interface VesselSelectModalState {
isOpen: boolean;
open: () => void;
reopen: () => void;
close: () => void;
selectedMmsis: Set<number>;
toggleMmsi: (mmsi: number) => void;
setMmsis: (mmsis: Set<number>) => void;
selectAllFiltered: (filtered: DerivedLegacyVessel[]) => void;
clearAll: () => void;
searchQuery: string;
setSearchQuery: (q: string) => void;
shipCodeFilter: Set<string>;
toggleShipCode: (code: string) => void;
toggleAllShipCodes: (allCodes: string[]) => void;
onlySailing: boolean;
setOnlySailing: (v: boolean) => void;
stateFilter: Set<string>;
toggleStateFilter: (label: string) => void;
toggleAllStates: (allLabels: string[]) => void;
startTime: string;
endTime: string;
setStartTime: (v: string) => void;
setEndTime: (v: string) => void;
applyPresetDays: (hours: number) => void;
isQuerying: boolean;
submitQuery: (allVessels: DerivedLegacyVessel[]) => void;
position: { x: number; y: number };
setPosition: (pos: { x: number; y: number }) => void;
selectionWarning: string | null;
groups: VesselGroup[];
saveGroup: (name: string, mmsis: number[]) => string | null;
deleteGroup: (id: string) => void;
applyGroup: (group: VesselGroup) => void;
}
export function useVesselSelectModal(userId: number | null = null): VesselSelectModalState {
const [isOpen, setIsOpen] = useState(false);
const [selectedMmsis, setSelectedMmsis] = useState<Set<number>>(new Set());
const [searchQuery, setSearchQuery] = useState('');
const [shipCodeFilter, setShipCodeFilter] = useState<Set<string>>(new Set());
const [onlySailing, setOnlySailing] = useState(false);
const [stateFilter, setStateFilter] = useState<Set<string>>(new Set());
const [selectionWarning, setSelectionWarning] = useState<string | null>(null);
const [isQuerying, setIsQuerying] = useState(false);
const [position, setPosition] = useState({ x: 0, y: 0 });
const [startTime, setStartTime] = useState(() => {
const n = Date.now();
return toDateTimeLocalKST(n - DEFAULT_DAYS * 86_400_000);
});
const [endTime, setEndTime] = useState(() => toDateTimeLocalKST(Date.now()));
const open = useCallback(() => {
setIsOpen(true);
setSelectedMmsis(new Set());
setSearchQuery('');
setShipCodeFilter(new Set());
setOnlySailing(false);
setStateFilter(new Set());
setSelectionWarning(null);
setIsQuerying(false);
setPosition({ x: 0, y: 0 });
const n = Date.now();
setStartTime(toDateTimeLocalKST(n - DEFAULT_DAYS * 86_400_000));
setEndTime(toDateTimeLocalKST(n));
}, []);
const reopen = useCallback(() => setIsOpen(true), []);
const close = useCallback(() => setIsOpen(false), []);
const toggleMmsi = useCallback((mmsi: number) => {
setSelectedMmsis((prev) => {
const next = new Set(prev);
if (next.has(mmsi)) {
next.delete(mmsi);
setSelectionWarning(null);
} else {
if (next.size >= MAX_VESSEL_SELECT) {
setSelectionWarning(`최대 ${MAX_VESSEL_SELECT}척까지 선택 가능합니다`);
return prev;
}
next.add(mmsi);
setSelectionWarning(null);
}
return next;
});
}, []);
const setMmsis = useCallback((mmsis: Set<number>) => {
if (mmsis.size > MAX_VESSEL_SELECT) {
setSelectionWarning(`최대 ${MAX_VESSEL_SELECT}척까지 선택 가능합니다`);
return;
}
setSelectedMmsis(mmsis);
setSelectionWarning(null);
}, []);
const { groups, saveGroup, deleteGroup, applyGroup } = useVesselGroups(userId, setMmsis);
const selectAllFiltered = useCallback((filtered: DerivedLegacyVessel[]) => {
const capped = filtered.slice(0, MAX_VESSEL_SELECT);
setSelectedMmsis(new Set(capped.map((v) => v.mmsi)));
if (filtered.length > MAX_VESSEL_SELECT) {
setSelectionWarning(`최대 ${MAX_VESSEL_SELECT}척까지 선택 가능합니다 (${filtered.length}척 중 ${MAX_VESSEL_SELECT}척 선택됨)`);
} else {
setSelectionWarning(null);
}
}, []);
const clearAll = useCallback(() => {
setSelectedMmsis(new Set());
setSelectionWarning(null);
}, []);
const toggleShipCode = useCallback((code: string) => {
setShipCodeFilter((prev) => {
const next = new Set(prev);
if (next.has(code)) next.delete(code);
else next.add(code);
return next;
});
}, []);
const toggleAllShipCodes = useCallback((allCodes: string[]) => {
setShipCodeFilter((prev) =>
prev.size === allCodes.length ? new Set() : new Set(allCodes),
);
}, []);
const toggleStateFilter = useCallback((label: string) => {
setStateFilter((prev) => {
const next = new Set(prev);
if (next.has(label)) next.delete(label);
else next.add(label);
return next;
});
}, []);
const toggleAllStates = useCallback((allLabels: string[]) => {
setStateFilter((prev) =>
prev.size === allLabels.length ? new Set() : new Set(allLabels),
);
}, []);
const applyPresetDays = useCallback(
(days: number) => {
const now = Date.now();
const spanMs = days * 86_400_000;
// datetime-local (KST) → ms (UTC)
const parseKST = (v: string) => new Date(`${v}:00+09:00`).getTime();
const curStart = parseKST(startTime);
const curEnd = parseKST(endTime);
const curGap = curEnd - curStart;
if (curGap > spanMs) {
// 현재 간격이 프리셋보다 넓으면 → 시작을 종료 기준으로 조정
const cappedEnd = Math.min(curEnd, now);
setEndTime(toDateTimeLocalKST(cappedEnd));
setStartTime(toDateTimeLocalKST(cappedEnd - spanMs));
} else {
// 시작 기준으로 종료 확장, 종료 max = 현재
const newEnd = Math.min(curStart + spanMs, now);
setEndTime(toDateTimeLocalKST(newEnd));
// 종료가 clamp 되었으면 시작도 조정
if (newEnd - curStart < spanMs) {
setStartTime(toDateTimeLocalKST(newEnd - spanMs));
}
}
},
[startTime, endTime],
);
const submitQuery = useCallback(
(allVessels: DerivedLegacyVessel[]) => {
const selected = allVessels.filter((v) => selectedMmsis.has(v.mmsi));
if (selected.length === 0) return;
const maxMs = MAX_QUERY_DAYS * 86_400_000;
const sMs = new Date(fromDateTimeLocalKST(startTime)).getTime();
const eMs = new Date(fromDateTimeLocalKST(endTime)).getTime();
if (eMs - sMs > maxMs) {
const clampedEnd = toDateTimeLocalKST(sMs + maxMs);
setEndTime(clampedEnd);
setSelectionWarning(`최대 ${MAX_QUERY_DAYS}일 초과 — 종료일을 자동 조정했습니다`);
return;
}
const vessels = selected.map((v) => ({
mmsi: v.mmsi,
shipNameHint: v.name,
isPermitted: true,
vesselType: v.shipCode,
ownerCn: v.ownerCn,
ownerRoman: v.ownerRoman,
permitNo: v.permitNo,
pairPermitNo: v.pairPermitNo,
ton: v.legacy.ton,
callSign: v.callsign ?? undefined,
workSeaArea: v.workSeaArea ?? undefined,
shipCode: v.shipCode,
}));
const ctx: MultiTrackQueryContext = {
vessels,
startTimeIso: fromDateTimeLocalKST(startTime),
endTimeIso: fromDateTimeLocalKST(endTime),
};
const queryKey = `multi:${selected.length}:${Date.now()}`;
useTrackQueryStore.getState().beginMultiQuery(queryKey, ctx);
setIsQuerying(true);
setIsOpen(false);
},
[selectedMmsis, startTime, endTime],
);
return {
isOpen,
open,
reopen,
close,
selectedMmsis,
toggleMmsi,
setMmsis,
selectAllFiltered,
clearAll,
searchQuery,
setSearchQuery,
shipCodeFilter,
toggleShipCode,
toggleAllShipCodes,
onlySailing,
setOnlySailing,
stateFilter,
toggleStateFilter,
toggleAllStates,
startTime,
endTime,
setStartTime,
setEndTime,
applyPresetDays,
isQuerying,
submitQuery,
position,
setPosition,
selectionWarning,
groups,
saveGroup,
deleteGroup,
applyGroup,
};
}

파일 보기

@ -1,3 +0,0 @@
export type { VesselDescriptor, VesselGroup } from './model/types';
export { MAX_VESSEL_SELECT, MAX_VESSEL_GROUPS } from './model/types';
export { useVesselSelectModal } from './hooks/useVesselSelectModal';

파일 보기

@ -1,27 +0,0 @@
export interface VesselDescriptor {
mmsi: number;
shipNameHint?: string;
shipKindCodeHint?: string;
nationalCodeHint?: string;
isPermitted: boolean;
vesselType?: string;
ownerCn?: string | null;
ownerRoman?: string | null;
permitNo?: string;
pairPermitNo?: string | null;
ton?: number | null;
callSign?: string;
workSeaArea?: string;
shipCode?: string;
}
export interface VesselGroup {
id: string;
name: string;
mmsis: number[];
updatedAt: number;
}
export const MAX_VESSEL_SELECT = 20;
export const MAX_QUERY_DAYS = 28;
export const MAX_VESSEL_GROUPS = 10;

파일 보기

@ -1,4 +1,4 @@
import { useCallback, useMemo, useRef, useState } from "react";
import { useCallback, useMemo, useState } from "react";
import { useAuth } from "../../shared/auth";
import { useTheme } from "../../shared/hooks";
import { useAisTargetPolling } from "../../features/aisPolling/useAisTargetPolling";
@ -22,7 +22,6 @@ import type { ShipImageInfo } from "../../entities/shipImage/model/types";
import ShipImageModal from "../../widgets/shipImage/ShipImageModal";
import { queryTrackByMmsi } from "../../features/trackReplay/services/trackQueryService";
import { useTrackQueryStore } from "../../features/trackReplay/stores/trackQueryStore";
import type { TrackQueryContext } from "../../features/trackReplay/model/track.types";
import { GlobalTrackReplayPanel } from "../../widgets/trackReplay/GlobalTrackReplayPanel";
import { useWeatherPolling } from "../../features/weatherOverlay/useWeatherPolling";
import { useWeatherOverlay } from "../../features/weatherOverlay/useWeatherOverlay";
@ -30,11 +29,7 @@ import { WeatherPanel } from "../../widgets/weatherPanel/WeatherPanel";
import { WeatherOverlayPanel } from "../../widgets/weatherOverlay/WeatherOverlayPanel";
import { MapSettingsPanel } from "../../features/mapSettings/MapSettingsPanel";
import { OceanMapSettingsPanel } from "../../features/oceanMap/ui/OceanMapSettingsPanel";
import { EncMapSettingsPanel } from "../../features/encMap/ui/EncMapSettingsPanel";
import { useEncMapSettings } from "../../features/encMap/hooks/useEncMapSettings";
import { useAnnouncementPopup, AnnouncementModal } from "../../features/announcement";
import { useVesselSelectModal } from "../../features/vesselSelect";
import { VesselSelectModal } from "../../widgets/vesselSelect/VesselSelectModal";
import {
buildLegacyHitMap,
computeCountsByType,
@ -71,9 +66,6 @@ export function DashboardPage() {
// ── Announcement popup ──
const { hasUnread, unreadAnnouncements, acknowledge } = useAnnouncementPopup(uid);
// ── Vessel select modal (multi-track) ──
const vesselSelectModal = useVesselSelectModal(uid);
// ── Data fetching ──
const { data: zones, error: zonesError } = useZones();
const { data: legacyData, error: legacyError } = useLegacyVessels();
@ -111,14 +103,6 @@ export function DashboardPage() {
alarmKindEnabled,
} = state;
// ── ENC map settings (runtime updates) ──
const mapRefForEnc = useRef<import('maplibre-gl').Map | null>(null);
const handleMapReadyWithRef = useCallback((map: import('maplibre-gl').Map) => {
mapRefForEnc.current = map;
handleMapReady(map);
}, [handleMapReady]);
useEncMapSettings(mapRefForEnc, baseMap, state.encMapSettings);
// ── Weather ──
const weather = useWeatherPolling(zones);
const weatherOverlay = useWeatherOverlay(mapInstance);
@ -150,33 +134,11 @@ export function DashboardPage() {
const handleRequestTrack = useCallback(async (mmsi: number, minutes: number) => {
const trackStore = useTrackQueryStore.getState();
const queryKey = `${mmsi}:${minutes}:${Date.now()}`;
const target = targets.find((item) => item.mmsi === mmsi);
const isPermitted = legacyHits.has(mmsi);
const endDate = new Date();
const startDate = new Date(endDate.getTime() - minutes * 60_000);
const legacy = legacyHits.get(mmsi);
const context: TrackQueryContext = {
mmsi,
startTimeIso: startDate.toISOString(),
endTimeIso: endDate.toISOString(),
shipNameHint: target?.name,
shipKindCodeHint: target?.shipKindCode,
nationalCodeHint: target?.nationalCode,
isPermitted,
vesselType: legacy?.shipCode,
ownerCn: legacy?.ownerCn,
ownerRoman: legacy?.ownerRoman,
permitNo: legacy?.permitNo,
pairPermitNo: legacy?.pairPermitNo,
ton: legacy?.ton,
callSign: legacy?.callSign,
workSeaArea: legacy?.workSeaArea,
};
trackStore.beginQuery(queryKey, context);
trackStore.beginQuery(queryKey);
try {
const target = targets.find((item) => item.mmsi === mmsi);
const isPermitted = legacyHits.has(mmsi);
const tracks = await queryTrackByMmsi({
mmsi,
minutes,
@ -366,7 +328,6 @@ export function DashboardPage() {
onToggleTheme={toggleTheme}
isSidebarOpen={isSidebarOpen}
onMenuToggle={() => setIsSidebarOpen((v) => !v)}
onOpenMultiTrack={vesselSelectModal.open}
/>
<DashboardSidebar
@ -452,20 +413,13 @@ export function DashboardPage() {
onRequestTrack={handleRequestTrack}
onCloseTrackMenu={handleCloseTrackMenu}
onOpenTrackMenu={handleOpenTrackMenu}
onMapReady={handleMapReadyWithRef}
onMapReady={handleMapReady}
alarmMmsiMap={alarmMmsiMap}
onClickShipPhoto={handleOpenImageModal}
freeCamera={state.freeCamera}
oceanMapSettings={state.oceanMapSettings}
encMapSettings={state.encMapSettings}
/>
<GlobalTrackReplayPanel
isVesselListOpen={vesselSelectModal.isOpen}
onToggleVesselList={() => {
if (vesselSelectModal.isOpen) vesselSelectModal.close();
else vesselSelectModal.reopen();
}}
/>
<GlobalTrackReplayPanel />
<WeatherPanel
snapshot={weather.snapshot}
isLoading={weather.isLoading}
@ -475,12 +429,10 @@ export function DashboardPage() {
<WeatherOverlayPanel {...weatherOverlay} />
{baseMap === 'ocean' ? (
<OceanMapSettingsPanel value={state.oceanMapSettings} onChange={state.setOceanMapSettings} />
) : baseMap === 'enc' ? (
<EncMapSettingsPanel value={state.encMapSettings} onChange={state.setEncMapSettings} />
) : (
<MapSettingsPanel value={mapStyleSettings} onChange={setMapStyleSettings} />
)}
{baseMap !== 'ocean' && baseMap !== 'enc' && <DepthLegend depthStops={mapStyleSettings.depthStops} />}
{baseMap !== 'ocean' && <DepthLegend depthStops={mapStyleSettings.depthStops} />}
<MapLegend />
{selectedLegacyVessel ? (
<VesselInfoPanel vessel={selectedLegacyVessel} allVessels={legacyVesselsAll} onClose={() => setSelectedMmsi(null)} onSelectMmsi={setSelectedMmsi} imo={selectedTarget && selectedTarget.imo > 0 ? selectedTarget.imo : undefined} shipImagePath={selectedTarget?.shipImagePath} shipImageCount={selectedTarget?.shipImageCount} onOpenImageModal={handlePanelOpenImageModal} />
@ -490,9 +442,6 @@ export function DashboardPage() {
{hasUnread && (
<AnnouncementModal announcements={unreadAnnouncements} onConfirm={acknowledge} />
)}
{vesselSelectModal.isOpen && (
<VesselSelectModal modal={vesselSelectModal} vessels={legacyVesselsAll} />
)}
{imageModal && (
<ShipImageModal
images={imageModal.images}

파일 보기

@ -157,10 +157,10 @@ export function DashboardSidebar({
</Section>
<Section
title="지도 설정"
title="지도 표시 설정"
className="md:shrink-0"
actions={
<div className="flex items-center gap-1">
<div className="flex gap-1">
<ToggleButton
on={freeCamera}
onClick={toggleFreeCamera}
@ -169,6 +169,14 @@ export function DashboardSidebar({
>
</ToggleButton>
<ToggleButton
on={baseMap === 'ocean'}
onClick={() => setBaseMap(baseMap === 'ocean' ? 'enhanced' : 'ocean')}
title="Ocean 전용 지도 (해양 정보 극대화)"
className="px-2 py-0.5 text-[9px]"
>
Ocean
</ToggleButton>
<ToggleButton
on={projection === 'globe'}
onClick={isProjectionToggleDisabled ? undefined : () => setProjection((p) => (p === 'globe' ? 'mercator' : 'globe'))}
@ -177,31 +185,6 @@ export function DashboardSidebar({
>
3D
</ToggleButton>
<span className="mx-0.5 h-3 w-px bg-wing-border" />
<ToggleButton
on={baseMap === 'enhanced'}
onClick={() => setBaseMap('enhanced')}
title="기본 지도 (MapTiler Enhanced)"
className="px-2 py-0.5 text-[9px]"
>
Base
</ToggleButton>
<ToggleButton
on={baseMap === 'enc'}
onClick={() => setBaseMap('enc')}
title="ENC 전자해도"
className="px-2 py-0.5 text-[9px]"
>
ENC
</ToggleButton>
<ToggleButton
on={baseMap === 'ocean'}
onClick={() => setBaseMap('ocean')}
title="Ocean 전용 지도 (해양 정보 극대화)"
className="px-2 py-0.5 text-[9px]"
>
Ocean
</ToggleButton>
</div>
}
>

파일 보기

@ -10,8 +10,6 @@ import { DEFAULT_MAP_STYLE_SETTINGS } from '../../features/mapSettings/types';
import type { MapStyleSettings } from '../../features/mapSettings/types';
import { DEFAULT_OCEAN_MAP_SETTINGS } from '../../features/oceanMap/model/types';
import type { OceanMapSettings } from '../../features/oceanMap/model/types';
import { DEFAULT_ENC_MAP_SETTINGS } from '../../features/encMap/model/types';
import type { EncMapSettings } from '../../features/encMap/model/types';
import { fmtDateTimeFull } from '../../shared/lib/datetime';
export type Bbox = [number, number, number, number];
@ -48,17 +46,14 @@ export function useDashboardState(uid: number | null) {
const [projection, setProjection] = useState<MapProjectionId>('mercator');
const [mapStyleSettings, setMapStyleSettings] = usePersistedState<MapStyleSettings>(uid, 'mapStyleSettings', DEFAULT_MAP_STYLE_SETTINGS);
const [overlays, setOverlays] = usePersistedState<MapToggleState>(uid, 'overlays', {
pairLines: false, pairRange: false, fcLines: false, zones: true,
fleetCircles: false, predictVectors: false, shipLabels: true, subcables: false,
pairLines: true, pairRange: true, fcLines: true, zones: true,
fleetCircles: true, predictVectors: true, shipLabels: true, subcables: false,
});
const [settings, setSettings] = usePersistedState<Map3DSettings>(uid, 'map3dSettings', {
showShips: true, showDensity: false, showSeamark: false,
});
const [mapView, setMapView] = usePersistedState<MapViewState | null>(uid, 'mapView', null);
const [oceanMapSettings, setOceanMapSettings] = usePersistedState<OceanMapSettings>(uid, 'oceanMapSettings', DEFAULT_OCEAN_MAP_SETTINGS);
const [encMapSettingsRaw, setEncMapSettings] = usePersistedState<EncMapSettings>(uid, 'encMapSettings', DEFAULT_ENC_MAP_SETTINGS);
// Merge with defaults to fill missing fields from older localStorage entries
const encMapSettings: EncMapSettings = { ...DEFAULT_ENC_MAP_SETTINGS, ...encMapSettingsRaw };
// ── 자유 시점 (모드별 독립) ──
const [freeCameraMercator, setFreeCameraMercator] = usePersistedState(uid, 'freeCameraMercator', true);
@ -73,7 +68,7 @@ export function useDashboardState(uid: number | null) {
const [fleetRelationSortMode, setFleetRelationSortMode] = usePersistedState<FleetRelationSortMode>(uid, 'sortMode', 'count');
const [alarmKindEnabled, setAlarmKindEnabled] = usePersistedState<Record<LegacyAlarmKind, boolean>>(
uid, 'alarmKindEnabled',
() => Object.fromEntries(LEGACY_ALARM_KINDS.map((k) => [k, false])) as Record<LegacyAlarmKind, boolean>,
() => Object.fromEntries(LEGACY_ALARM_KINDS.map((k) => [k, true])) as Record<LegacyAlarmKind, boolean>,
);
// ── Fleet focus ──
@ -84,8 +79,8 @@ export function useDashboardState(uid: number | null) {
const [selectedCableId, setSelectedCableId] = useState<string | null>(null);
// ── Track context menu ──
const [trackContextMenu, setTrackContextMenu] = useState<{ x: number; y: number; mmsi: number; vesselName: string; isPermitted: boolean } | null>(null);
const handleOpenTrackMenu = useCallback((info: { x: number; y: number; mmsi: number; vesselName: string; isPermitted: boolean }) => setTrackContextMenu(info), []);
const [trackContextMenu, setTrackContextMenu] = useState<{ x: number; y: number; mmsi: number; vesselName: string } | null>(null);
const handleOpenTrackMenu = useCallback((info: { x: number; y: number; mmsi: number; vesselName: string }) => setTrackContextMenu(info), []);
const handleCloseTrackMenu = useCallback(() => setTrackContextMenu(null), []);
// ── Projection loading ──
@ -147,9 +142,7 @@ export function useDashboardState(uid: number | null) {
baseMap, setBaseMap, projection, setProjection,
mapStyleSettings, setMapStyleSettings,
overlays, setOverlays, settings, setSettings,
mapView, setMapView, freeCamera, toggleFreeCamera,
oceanMapSettings, setOceanMapSettings,
encMapSettings, setEncMapSettings,
mapView, setMapView, freeCamera, toggleFreeCamera, oceanMapSettings, setOceanMapSettings,
fleetRelationSortMode, setFleetRelationSortMode,
alarmKindEnabled, setAlarmKindEnabled,
fleetFocus, setFleetFocus,

파일 보기

@ -1,4 +1,18 @@
// ── Shared map constants ──
// Moved from widgets/map3d/constants.ts to resolve FSD layer violation
// (features/ must not import from widgets/).
export const SHIP_ICON_MAPPING = {
ship: {
x: 0,
y: 0,
width: 128,
height: 128,
anchorX: 64,
anchorY: 64,
mask: true,
},
} as const;
export const DEPTH_DISABLED_PARAMS = {
depthCompare: 'always',

파일 보기

@ -1,187 +0,0 @@
// ── 선종(Ship Kind) 상수 + SVG 아이콘 생성 ──
// gc-wing-simple의 SVG 기반 선종별 아이콘 시스템을 도입.
// 기타 AIS: 선종별 색상 + 이동(화살표)/정지(원형) 분리.
// 대상 선박: legacy code 색상 + 약간 더 큰 SVG + 흰색 테두리.
import { LEGACY_CODE_COLORS_RGB, rgbToHex, type Rgb } from './palette';
// ── 선종 상수 (8종) ──
export const SIGNAL_KIND = {
FISHING: '000020',
KCGV: '000021',
PASSENGER: '000022',
CARGO: '000023',
TANKER: '000024',
GOV: '000025',
NORMAL: '000027',
BUOY: '000028',
} as const;
export type SignalKindCode = (typeof SIGNAL_KIND)[keyof typeof SIGNAL_KIND] | string;
/** 선종별 한글 라벨 */
export const SHIP_KIND_LABELS: Record<string, string> = {
'000020': '어선',
'000021': '경비함정',
'000022': '여객선',
'000023': '화물선',
'000024': '유조선',
'000025': '관공선',
'000027': '일반',
'000028': '부이',
};
/** 선종별 범례/UI 색상 (hex) */
export const SHIP_KIND_COLORS: Record<string, string> = {
'000020': '#00C853',
'000021': '#FF5722',
'000022': '#2196F3',
'000023': '#9C27B0',
'000024': '#F44336',
'000025': '#FF9800',
'000027': '#607D8B',
'000028': '#795548',
};
/** 정렬된 선종 코드 목록 (범례 표시 순서) */
export const SHIP_KIND_ORDER = [
'000020', '000021', '000022', '000023',
'000024', '000025', '000027', '000028',
] as const;
// ── SVG 아이콘 생성기 ──
const STROKE = 'rgba(0,0,0,0.6)';
const TARGET_STROKE = 'rgba(255,255,255,0.7)';
/** 이동 중 선박 SVG (화살표 형태, 32×48) */
export function makeMovingShipSvg(fill: string): string {
return `<svg xmlns="http://www.w3.org/2000/svg" width="32" height="48" viewBox="0 0 32 48"><path d="M16 2 L8 13 L4 28 L7 45 L25 45 L28 28 L24 13 Z" fill="${fill}" stroke="${STROKE}" stroke-width="1.5" stroke-linejoin="round"/></svg>`;
}
/** 정지 선박 SVG (원형, 16×16) */
export function makeStoppedShipSvg(fill: string): string {
return `<svg xmlns="http://www.w3.org/2000/svg" width="16" height="16" viewBox="0 0 16 16"><circle cx="8" cy="8" r="5.5" fill="${fill}" stroke="${STROKE}" stroke-width="1.2"/></svg>`;
}
/** 부이 SVG (다색, 32×44) */
export function makeBuoySvg(): string {
return `<svg xmlns="http://www.w3.org/2000/svg" width="32" height="44" viewBox="0 0 32 44"><line x1="16" y1="10" x2="16" y2="38" stroke="#5D4037" stroke-width="2.5" stroke-linecap="round"/><line x1="10" y1="38" x2="22" y2="38" stroke="#5D4037" stroke-width="2" stroke-linecap="round"/><ellipse cx="16" cy="24" rx="9" ry="7" fill="#E53935" stroke="#333" stroke-width="1"/><rect x="8" y="22" width="16" height="4" rx="1" fill="#FDD835" opacity="0.85"/><rect x="14.5" y="8" width="3" height="10" fill="#666"/><circle cx="16" cy="7" r="3.5" fill="#FFC107" stroke="#444" stroke-width="0.8"/></svg>`;
}
/** 대상 선박 이동 SVG (36×52, 흰색 반투명 테두리) */
export function makeTargetMovingShipSvg(fill: string): string {
return `<svg xmlns="http://www.w3.org/2000/svg" width="36" height="52" viewBox="0 0 36 52"><path d="M18 2 L9 14 L5 30 L8 49 L28 49 L31 30 L27 14 Z" fill="${fill}" stroke="${TARGET_STROKE}" stroke-width="2" stroke-linejoin="round"/></svg>`;
}
/** 대상 선박 정지 SVG (20×20, 흰색 반투명 테두리) */
export function makeTargetStoppedShipSvg(fill: string): string {
return `<svg xmlns="http://www.w3.org/2000/svg" width="20" height="20" viewBox="0 0 20 20"><circle cx="10" cy="10" r="7" fill="${fill}" stroke="${TARGET_STROKE}" stroke-width="1.5"/></svg>`;
}
function toDataUri(svg: string): string {
return `data:image/svg+xml;charset=utf-8,${encodeURIComponent(svg)}`;
}
/** Deck.gl IconLayer getIcon 반환 타입 */
export interface ShipIconSpec {
url: string;
width: number;
height: number;
anchorX: number;
anchorY: number;
}
// ── 기타 AIS 아이콘 스펙 사전 생성 (8종 × 2상태 + buoy) ──
const OTHER_ICON_SPECS: Record<string, ShipIconSpec> = {};
for (const code of SHIP_KIND_ORDER) {
const color = SHIP_KIND_COLORS[code] || '#607D8B';
if (code === '000028') {
OTHER_ICON_SPECS[`${code}-buoy`] = {
url: toDataUri(makeBuoySvg()),
width: 32, height: 44, anchorX: 16, anchorY: 22,
};
continue;
}
OTHER_ICON_SPECS[`${code}-moving`] = {
url: toDataUri(makeMovingShipSvg(color)),
width: 32, height: 48, anchorX: 16, anchorY: 24,
};
OTHER_ICON_SPECS[`${code}-stopped`] = {
url: toDataUri(makeStoppedShipSvg(color)),
width: 16, height: 16, anchorX: 8, anchorY: 8,
};
}
// fallback
const FALLBACK_MOVING: ShipIconSpec = OTHER_ICON_SPECS['000027-moving'];
const FALLBACK_STOPPED: ShipIconSpec = OTHER_ICON_SPECS['000027-stopped'];
// ── 대상 선박 아이콘 스펙 사전 생성 (7 legacyCode × 2상태) ──
const LEGACY_CODES = ['PT', 'PT-S', 'GN', 'OT', 'PS', 'FC', 'C21'] as const;
const TARGET_ICON_SPECS: Record<string, ShipIconSpec> = {};
for (const code of LEGACY_CODES) {
const rgb: Rgb = LEGACY_CODE_COLORS_RGB[code] || [100, 116, 139];
const hex = rgbToHex(rgb);
TARGET_ICON_SPECS[`${code}-moving`] = {
url: toDataUri(makeTargetMovingShipSvg(hex)),
width: 36, height: 52, anchorX: 18, anchorY: 26,
};
TARGET_ICON_SPECS[`${code}-stopped`] = {
url: toDataUri(makeTargetStoppedShipSvg(hex)),
width: 20, height: 20, anchorX: 10, anchorY: 10,
};
}
// fallback (FC 색상)
const TARGET_FALLBACK_MOVING: ShipIconSpec = TARGET_ICON_SPECS['FC-moving'];
const TARGET_FALLBACK_STOPPED: ShipIconSpec = TARGET_ICON_SPECS['FC-stopped'];
// ── SOG 기준 이동/정지 판단 (kn) ──
export const SPEED_THRESHOLD_KN = 1;
// ── 조회 함수 ──
/** 기타 AIS 아이콘 스펙 조회 */
export function getShipIconSpec(
signalKindCode: string | undefined | null,
sog: number | undefined | null,
): ShipIconSpec {
const code = signalKindCode || '000027';
if (code === '000028') return OTHER_ICON_SPECS['000028-buoy'] || FALLBACK_STOPPED;
const isMoving = Number.isFinite(sog) && (sog as number) > SPEED_THRESHOLD_KN;
const key = `${code}-${isMoving ? 'moving' : 'stopped'}`;
return OTHER_ICON_SPECS[key] || (isMoving ? FALLBACK_MOVING : FALLBACK_STOPPED);
}
/** 대상 선박 아이콘 스펙 조회 */
export function getTargetShipIconSpec(
legacyShipCode: string | undefined | null,
sog: number | undefined | null,
): ShipIconSpec {
const code = legacyShipCode || 'FC';
const isMoving = Number.isFinite(sog) && (sog as number) > SPEED_THRESHOLD_KN;
const key = `${code}-${isMoving ? 'moving' : 'stopped'}`;
return TARGET_ICON_SPECS[key] || (isMoving ? TARGET_FALLBACK_MOVING : TARGET_FALLBACK_STOPPED);
}
/** 선박 아이콘 회전각 (부이는 0, 나머지는 -cog) */
export function getShipIconAngle(
signalKindCode: string | undefined | null,
cog: number | undefined | null,
): number {
const code = signalKindCode || '000027';
if (code === '000028') return 0;
return -(Number.isFinite(cog) ? (cog as number) : 0);
}

파일 보기

@ -1,7 +1,6 @@
import { useState } from 'react';
import { ZONE_IDS, ZONE_META } from "../../entities/zone/model/meta";
import { LEGACY_CODE_COLORS_HEX, OVERLAY_RGB, rgbToHex, rgba } from "../../shared/lib/map/palette";
import { SHIP_KIND_ORDER, SHIP_KIND_LABELS, SHIP_KIND_COLORS } from "../../shared/lib/map/shipKind";
import { LEGACY_CODE_COLORS_HEX, OTHER_AIS_SPEED_HEX, OVERLAY_RGB, rgbToHex, rgba } from "../../shared/lib/map/palette";
export function MapLegend() {
const [isOpen, setIsOpen] = useState(true);
@ -26,13 +25,23 @@ export function MapLegend() {
</div>
))}
<div className="lt" style={{ marginTop: 8 }}> AIS ()</div>
{SHIP_KIND_ORDER.filter((c) => c !== '000028').map((code) => (
<div key={code} className="li">
<div className="ls" style={{ background: SHIP_KIND_COLORS[code], borderRadius: 999 }} />
{SHIP_KIND_LABELS[code]}
</div>
))}
<div className="lt" style={{ marginTop: 8 }}> AIS ()</div>
<div className="li">
<div className="ls" style={{ background: OTHER_AIS_SPEED_HEX.fast, borderRadius: 999 }} />
SOG 10 kt
</div>
<div className="li">
<div className="ls" style={{ background: OTHER_AIS_SPEED_HEX.moving, borderRadius: 999 }} />
1 SOG &lt; 10 kt
</div>
<div className="li">
<div className="ls" style={{ background: OTHER_AIS_SPEED_HEX.stopped, borderRadius: 999 }} />
SOG &lt; 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">

파일 보기

@ -29,7 +29,6 @@ import { useSubcablesLayer } from './hooks/useSubcablesLayer';
import { useTrackReplayLayer } from './hooks/useTrackReplayLayer';
import { useMapStyleSettings } from './hooks/useMapStyleSettings';
import { useOceanMapSettings } from '../../features/oceanMap/hooks/useOceanMapSettings';
import { useShipLabelColor } from './hooks/useShipLabelColor';
import { VesselContextMenu } from './components/VesselContextMenu';
import { useLiveShipAdapter } from '../../features/liveRenderer/hooks/useLiveShipAdapter';
import { useLiveShipBatchRender } from '../../features/liveRenderer/hooks/useLiveShipBatchRender';
@ -87,7 +86,6 @@ export function Map3D({
onClickShipPhoto,
freeCamera = true,
oceanMapSettings,
encMapSettings,
}: Props) {
// ── Shared refs ──────────────────────────────────────────────────────
const containerRef = useRef<HTMLDivElement | null>(null);
@ -580,7 +578,6 @@ export function Map3D({
useMapStyleSettings(mapRef, mapStyleSettings, { baseMap, mapSyncEpoch });
useOceanMapSettings(mapRef, oceanMapSettings, { baseMap, mapSyncEpoch });
const shipLabelColors = useShipLabelColor(mapRef, baseMap, mapSyncEpoch, encMapSettings);
useZonesLayer(
mapRef, projectionBusyRef, reorderGlobeFeatureLayers,
@ -627,7 +624,7 @@ export function Map3D({
useDeckLayers(
mapRef, overlayRef, globeDeckLayerRef, projectionBusyRef,
{
projection, settings, trackReplayDeckLayers: trackReplayRenderState.trackReplayDeckLayers, shipLayerData, shipData,
projection, settings, trackReplayDeckLayers: trackReplayRenderState.trackReplayDeckLayers, shipLayerData, shipOverlayLayerData, shipData,
legacyHits, pairLinks, fcLinks, fcDashed, fleetCircles, pairRanges,
pairLinksInteractive, pairRangesInteractive, fcLinesInteractive, fleetCirclesInteractive,
overlays, shipByMmsi, selectedMmsi, shipHighlightSet,
@ -638,7 +635,6 @@ export function Map3D({
onDeckSelectOrHighlight, onSelectMmsi: onMapSelectMmsi, onToggleHighlightMmsi,
ensureMercatorOverlay, alarmMmsiMap,
onClickShipPhoto,
shipLabelColors,
},
);
@ -712,12 +708,11 @@ export function Map3D({
if (hovered.length > 0) mmsi = hovered[0];
}
if (mmsi == null) return;
if (mmsi == null || !legacyHits?.has(mmsi)) return;
const target = shipByMmsi.get(mmsi);
const vesselName = (target?.name || '').trim() || `MMSI ${mmsi}`;
const isPermitted = legacyHits?.has(mmsi) ?? false;
onOpenTrackMenu({ x: e.clientX, y: e.clientY, mmsi, vesselName, isPermitted });
onOpenTrackMenu({ x: e.clientX, y: e.clientY, mmsi, vesselName });
};
container.addEventListener('contextmenu', onContextMenu);
return () => container.removeEventListener('contextmenu', onContextMenu);
@ -739,14 +734,13 @@ export function Map3D({
return (
<>
<div ref={containerRef} style={{ width: '100%', height: '100%' }} />
{trackContextMenu && onCloseTrackMenu && (
{trackContextMenu && onRequestTrack && onCloseTrackMenu && (
<VesselContextMenu
x={trackContextMenu.x}
y={trackContextMenu.y}
mmsi={trackContextMenu.mmsi}
vesselName={trackContextMenu.vesselName}
isPermitted={trackContextMenu.isPermitted}
onRequestTrack={onRequestTrack ?? undefined}
onRequestTrack={onRequestTrack}
onClose={onCloseTrackMenu}
/>
)}

파일 보기

@ -1,12 +1,11 @@
import { useEffect, useRef, useState, type CSSProperties } from 'react';
import { useEffect, useRef } from 'react';
interface Props {
x: number;
y: number;
mmsi: number;
vesselName: string;
isPermitted: boolean;
onRequestTrack?: (mmsi: number, minutes: number) => void;
onRequestTrack: (mmsi: number, minutes: number) => void;
onClose: () => void;
}
@ -21,40 +20,12 @@ const TRACK_OPTIONS = [
const MENU_WIDTH = 180;
const MENU_PAD = 8;
const STYLE_ITEM: CSSProperties = {
display: 'block',
width: '100%',
padding: '5px 12px 5px 24px',
background: 'none',
border: 'none',
color: '#e2e2e2',
fontSize: 12,
textAlign: 'left',
cursor: 'pointer',
lineHeight: 1.4,
};
const STYLE_SEPARATOR: CSSProperties = {
height: 1,
background: 'rgba(255,255,255,0.08)',
margin: '3px 0',
};
function handleHover(e: React.MouseEvent) {
(e.target as HTMLElement).style.background = 'rgba(59,130,246,0.18)';
}
function handleLeave(e: React.MouseEvent) {
(e.target as HTMLElement).style.background = 'none';
}
export function VesselContextMenu({ x, y, mmsi, vesselName, isPermitted, onRequestTrack, onClose }: Props) {
export function VesselContextMenu({ x, y, mmsi, vesselName, onRequestTrack, onClose }: Props) {
const ref = useRef<HTMLDivElement>(null);
const [copiedField, setCopiedField] = useState<'name' | 'mmsi' | null>(null);
const estimatedHeight = (isPermitted && onRequestTrack ? TRACK_OPTIONS.length * 30 + 56 : 0) + 90;
// 화면 밖 보정
const left = Math.min(x, window.innerWidth - MENU_WIDTH - MENU_PAD);
const maxTop = window.innerHeight - estimatedHeight - MENU_PAD;
const maxTop = window.innerHeight - (TRACK_OPTIONS.length * 30 + 56) - MENU_PAD;
const top = Math.min(y, maxTop);
useEffect(() => {
@ -76,18 +47,8 @@ export function VesselContextMenu({ x, y, mmsi, vesselName, isPermitted, onReque
};
}, [onClose]);
const handleCopy = async (text: string, field: 'name' | 'mmsi') => {
try {
await navigator.clipboard.writeText(text);
setCopiedField(field);
setTimeout(() => setCopiedField(null), 1200);
} catch {
// clipboard API 불가 시 무시
}
};
const handleSelectTrack = (minutes: number) => {
onRequestTrack?.(mmsi, minutes);
const handleSelect = (minutes: number) => {
onRequestTrack(mmsi, minutes);
onClose();
};
@ -131,56 +92,44 @@ export function VesselContextMenu({ x, y, mmsi, vesselName, isPermitted, onReque
{vesselName}
</div>
{/* 선명 복사 */}
<button
type="button"
style={STYLE_ITEM}
onClick={() => handleCopy(vesselName, 'name')}
onMouseEnter={handleHover}
onMouseLeave={handleLeave}
{/* 항적조회 항목 */}
<div
style={{
padding: '4px 12px 2px',
fontSize: 11,
fontWeight: 600,
color: 'rgba(255,255,255,0.6)',
}}
>
{copiedField === 'name' ? '복사됨' : '선명 복사'}
</button>
</div>
{/* MMSI 복사 */}
<button
type="button"
style={STYLE_ITEM}
onClick={() => handleCopy(String(mmsi), 'mmsi')}
onMouseEnter={handleHover}
onMouseLeave={handleLeave}
>
{copiedField === 'mmsi' ? '복사됨' : 'MMSI 복사'}
</button>
{/* 항적조회 (대상선박만) */}
{isPermitted && onRequestTrack && (
<>
<div style={STYLE_SEPARATOR} />
<div
style={{
padding: '4px 12px 2px',
fontSize: 11,
fontWeight: 600,
color: 'rgba(255,255,255,0.6)',
}}
>
</div>
{TRACK_OPTIONS.map((opt) => (
<button
type="button"
key={opt.minutes}
onClick={() => handleSelectTrack(opt.minutes)}
style={STYLE_ITEM}
onMouseEnter={handleHover}
onMouseLeave={handleLeave}
>
{opt.label}
</button>
))}
</>
)}
{TRACK_OPTIONS.map((opt) => (
<button
key={opt.minutes}
onClick={() => handleSelect(opt.minutes)}
style={{
display: 'block',
width: '100%',
padding: '5px 12px 5px 24px',
background: 'none',
border: 'none',
color: '#e2e2e2',
fontSize: 12,
textAlign: 'left',
cursor: 'pointer',
lineHeight: 1.4,
}}
onMouseEnter={(e) => {
(e.target as HTMLElement).style.background = 'rgba(59,130,246,0.18)';
}}
onMouseLeave={(e) => {
(e.target as HTMLElement).style.background = 'none';
}}
>
{opt.label}
</button>
))}
</div>
);
}

파일 보기

@ -14,7 +14,10 @@ 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 removed — now using shipKind.ts SVG-based icons
// ── Ship icon mapping (Deck.gl IconLayer) ──
// Canonical source: shared/lib/map/mapConstants.ts (re-exported for local usage)
export { SHIP_ICON_MAPPING } from '../../shared/lib/map/mapConstants';
// ── Ship constants ──
@ -44,20 +47,14 @@ export const GLOBE_OUTLINE_OTHER = 'rgba(160,175,195,0.55)';
// ── Flat map icon sizes ──
export const FLAT_OTHER_SHIP_SIZE = 20;
export const FLAT_TARGET_SHIP_SIZE = 26;
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>();
// ── 대상 선박 브리딩 애니메이션 ──
export const HALO_BREATHE_PERIOD_MS = 2000;
export const HALO_BREATHE_SELECTED_R_MIN = 16;
export const HALO_BREATHE_SELECTED_R_MAX = 22;
export const HALO_BREATHE_HIGHLIGHTED_R_MIN = 14;
export const HALO_BREATHE_HIGHLIGHTED_R_MAX = 19;
// ── Deck.gl view ID ──
export const DECK_VIEW_ID = 'mapbox';

파일 보기

@ -19,32 +19,26 @@ export function useBaseMapToggle(
const showSeamarkRef = useRef(showSeamark);
const bathyZoomProfileKeyRef = useRef<string>('');
const initialLoadRef = useRef(true);
useEffect(() => {
showSeamarkRef.current = showSeamark;
}, [showSeamark]);
// Base map style toggle — skip first run (useMapInit handles initial style)
// Base map style toggle
useEffect(() => {
if (initialLoadRef.current) {
initialLoadRef.current = false;
return;
}
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 });
map.once('style.load', () => {
if (cancelled) return;
stop = onMapStyleReady(map, () => {
kickRepaint(map);
requestAnimationFrame(() => kickRepaint(map));
pulseMapSync();
@ -58,6 +52,7 @@ export function useBaseMapToggle(
return () => {
cancelled = true;
controller.abort();
stop?.();
};
}, [baseMap]);
@ -68,7 +63,6 @@ export function useBaseMapToggle(
const apply = () => {
if (!map.isStyleLoaded()) return;
if (baseMap === 'enc') return;
const seaVisibility = 'visible' as const;
const seaRegex = /(water|sea|ocean|river|lake|coast|bay)/i;

파일 보기

@ -3,7 +3,7 @@ import { MapboxOverlay } from '@deck.gl/mapbox';
import { type PickingInfo } from '@deck.gl/core';
import type { AisTarget } from '../../../entities/aisTarget/model/types';
import type { LegacyVesselInfo } from '../../../entities/legacyVessel/model/types';
import { IconLayer, ScatterplotLayer } from '@deck.gl/layers';
import { ScatterplotLayer } from '@deck.gl/layers';
import { ALARM_BADGE, type LegacyAlarmKind } from '../../../features/legacyDashboard/model/types';
import type { FcLink, FleetCircle, PairLink } from '../../../features/legacyDashboard/model/types';
import type { MapToggleState } from '../../../features/mapToggles/MapToggles';
@ -19,26 +19,11 @@ import {
} from '../lib/tooltips';
import { sanitizeDeckLayerList } from '../lib/mapCore';
import { buildMercatorDeckLayers, buildGlobeDeckLayers } from '../lib/deckLayerFactories';
import {
FLAT_LEGACY_HALO_RADIUS,
FLAT_LEGACY_HALO_RADIUS_SELECTED,
HALO_BREATHE_PERIOD_MS,
HALO_BREATHE_SELECTED_R_MIN,
HALO_BREATHE_SELECTED_R_MAX,
HALO_BREATHE_HIGHLIGHTED_R_MIN,
HALO_BREATHE_HIGHLIGHTED_R_MAX,
} from '../constants';
// NOTE:
// Globe mode now relies on MapLibre native overlays (useGlobeOverlays/useGlobeShips).
// Keep Deck custom-layer rendering disabled in globe to avoid projection-space artifacts.
const ENABLE_GLOBE_DECK_OVERLAYS = false;
/** 64×64 white ring SVG for alarm pulse IconLayer */
const ALARM_RING_ICON_URL = `data:image/svg+xml,${encodeURIComponent(
'<svg xmlns="http://www.w3.org/2000/svg" width="64" height="64"><circle cx="32" cy="32" r="27" fill="none" stroke="white" stroke-width="5"/></svg>',
)}`;
const ALARM_RING_ICON_MAPPING = { ring: { x: 0, y: 0, width: 64, height: 64, mask: true } } as const;
export function useDeckLayers(
mapRef: MutableRefObject<import('maplibre-gl').Map | null>,
@ -50,6 +35,7 @@ export function useDeckLayers(
settings: Map3DSettings;
trackReplayDeckLayers: unknown[];
shipLayerData: AisTarget[];
shipOverlayLayerData: AisTarget[];
shipData: AisTarget[];
legacyHits: Map<number, LegacyVesselInfo> | null | undefined;
pairLinks: PairLink[] | undefined;
@ -83,11 +69,10 @@ export function useDeckLayers(
ensureMercatorOverlay: () => MapboxOverlay | null;
alarmMmsiMap?: Map<number, LegacyAlarmKind>;
onClickShipPhoto?: (mmsi: number) => void;
shipLabelColors?: import('../lib/labelColor').ShipLabelColors;
},
) {
const {
projection, settings, trackReplayDeckLayers, shipLayerData, shipData,
projection, settings, trackReplayDeckLayers, shipLayerData, shipOverlayLayerData, shipData,
legacyHits, pairLinks, fcDashed, fleetCircles, pairRanges,
pairLinksInteractive, pairRangesInteractive, fcLinesInteractive, fleetCirclesInteractive,
overlays, shipByMmsi, selectedMmsi, shipHighlightSet,
@ -98,15 +83,12 @@ export function useDeckLayers(
onDeckSelectOrHighlight, onSelectMmsi, onToggleHighlightMmsi,
ensureMercatorOverlay, alarmMmsiMap,
onClickShipPhoto,
shipLabelColors,
} = opts;
// Use shipLayerData (clustered/visible) instead of shipData (all) so halo
// only appears for targets that are currently rendered after clustering.
const legacyTargets = useMemo(() => {
if (!legacyHits) return [];
return shipLayerData.filter((t) => legacyHits.has(t.mmsi));
}, [shipLayerData, legacyHits]);
return shipData.filter((t) => legacyHits.has(t.mmsi));
}, [shipData, legacyHits]);
const legacyTargetsOrdered = useMemo(() => {
if (legacyTargets.length === 0) return legacyTargets;
@ -116,17 +98,14 @@ export function useDeckLayers(
}, [legacyTargets]);
const legacyOverlayTargets = useMemo(() => {
if (shipHighlightSet.size === 0 && selectedMmsi == null) return [];
return legacyTargets.filter((target) =>
shipHighlightSet.has(target.mmsi) ||
(selectedMmsi != null && target.mmsi === selectedMmsi),
);
}, [legacyTargets, shipHighlightSet, selectedMmsi]);
if (shipHighlightSet.size === 0) return [];
return legacyTargets.filter((target) => shipHighlightSet.has(target.mmsi));
}, [legacyTargets, shipHighlightSet]);
const alarmTargets = useMemo(() => {
if (!alarmMmsiMap || alarmMmsiMap.size === 0) return [];
return shipLayerData.filter((t) => alarmMmsiMap.has(t.mmsi));
}, [shipLayerData, alarmMmsiMap]);
return shipData.filter((t) => alarmMmsiMap.has(t.mmsi));
}, [shipData, alarmMmsiMap]);
const shipPhotoTargets = useMemo(() => {
return shipData.filter((t) => !!t.shipImagePath);
@ -155,6 +134,7 @@ export function useDeckLayers(
const layers = buildMercatorDeckLayers({
shipLayerData,
shipOverlayLayerData,
legacyTargetsOrdered,
legacyOverlayTargets,
legacyHits,
@ -188,7 +168,6 @@ export function useDeckLayers(
alarmPulseHoverRadius: 12,
shipPhotoTargets,
onClickShipPhoto,
shipLabelColors,
});
const normalizedBaseLayers = sanitizeDeckLayerList(layers);
@ -267,6 +246,7 @@ export function useDeckLayers(
legacyTargetsOrdered,
legacyHits,
legacyOverlayTargets,
shipOverlayLayerData,
pairRangesInteractive,
pairLinksInteractive,
fcLinesInteractive,
@ -293,15 +273,11 @@ export function useDeckLayers(
alarmMmsiMap,
shipPhotoTargets,
onClickShipPhoto,
shipLabelColors,
]);
// Mercator 브리딩 애니메이션 (rAF) — 알람 맥동 + 대상 선박 브리딩 합류
const hasAlarms = alarmMmsiMap && alarmMmsiMap.size > 0 && alarmTargets.length > 0;
const hasTargetOverlays = legacyOverlayTargets.length > 0;
// Mercator alarm pulse breathing animation (rAF)
useEffect(() => {
if (projection !== 'mercator' || (!hasAlarms && !hasTargetOverlays)) {
if (projection !== 'mercator' || !alarmMmsiMap || alarmMmsiMap.size === 0 || alarmTargets.length === 0) {
if (alarmRafRef.current) cancelAnimationFrame(alarmRafRef.current);
alarmRafRef.current = 0;
return;
@ -319,75 +295,34 @@ export function useDeckLayers(
return;
}
const now = Date.now();
let updated = mercatorLayersRef.current;
const t = (Math.sin(Date.now() / 1500 * Math.PI * 2) + 1) / 2;
const normalR = 8 + t * 6;
const hoverR = 12 + t * 6;
// 1. 알람 맥동 — IconLayer + SVG ring, opacity/sizeScale uniform 애니메이션
if (hasAlarms) {
const tA = (Math.sin(now / 1500 * Math.PI * 2) + 1) / 2;
const pulseLyr = new ScatterplotLayer<AisTarget>({
id: 'alarm-pulse',
data: alarmTargets,
pickable: false,
billboard: false,
filled: true,
stroked: false,
radiusUnits: 'pixels',
getRadius: (d) => {
const isHover = (selectedMmsi != null && d.mmsi === selectedMmsi) || shipHighlightSet.has(d.mmsi);
return isHover ? hoverR : normalR;
},
getFillColor: (d) => {
const kind = alarmMmsiMap.get(d.mmsi);
return kind ? ALARM_BADGE[kind].rgba : [107, 114, 128, 90] as [number, number, number, number];
},
getPosition: (d) => [d.lon, d.lat] as [number, number],
updateTriggers: { getRadius: [normalR, hoverR] },
});
const pulseLyr = new IconLayer<AisTarget>({
id: 'alarm-pulse',
data: alarmTargets,
pickable: false,
billboard: true,
iconAtlas: ALARM_RING_ICON_URL,
iconMapping: ALARM_RING_ICON_MAPPING,
getIcon: () => 'ring',
sizeUnits: 'pixels',
sizeScale: 0.9 + tA * 0.2,
opacity: 0.2 + tA * 0.7,
getSize: (d) => {
const isHover = (selectedMmsi != null && d.mmsi === selectedMmsi) || shipHighlightSet.has(d.mmsi);
return isHover
? (FLAT_LEGACY_HALO_RADIUS_SELECTED + 8) * 2
: (FLAT_LEGACY_HALO_RADIUS + 8) * 2;
},
getColor: (d) => {
const kind = alarmMmsiMap!.get(d.mmsi);
return kind ? ALARM_BADGE[kind].rgba : [107, 114, 128, 200] as [number, number, number, number];
},
getPosition: (d) => [d.lon, d.lat] as [number, number],
updateTriggers: {
getSize: [selectedMmsi],
},
});
updated = updated.map((l) =>
// eslint-disable-next-line @typescript-eslint/no-explicit-any
(l as any)?.id === 'alarm-pulse' ? pulseLyr : l,
);
}
// 2. 대상 선박 브리딩 링
if (hasTargetOverlays) {
const tH = (Math.sin(now / HALO_BREATHE_PERIOD_MS * Math.PI * 2) + 1) / 2;
const selR = HALO_BREATHE_SELECTED_R_MIN + tH * (HALO_BREATHE_SELECTED_R_MAX - HALO_BREATHE_SELECTED_R_MIN);
const hlR = HALO_BREATHE_HIGHLIGHTED_R_MIN + tH * (HALO_BREATHE_HIGHLIGHTED_R_MAX - HALO_BREATHE_HIGHLIGHTED_R_MIN);
const alpha = Math.round(155 + tH * 100);
const haloLyr = new ScatterplotLayer<AisTarget>({
id: 'legacy-halo-overlay',
data: legacyOverlayTargets,
pickable: false,
billboard: false,
filled: false,
stroked: true,
radiusUnits: 'pixels',
getRadius: (d) => (selectedMmsi != null && d.mmsi === selectedMmsi ? selR : hlR),
lineWidthUnits: 'pixels',
getLineWidth: 2.5,
getLineColor: (d) => {
if (selectedMmsi != null && d.mmsi === selectedMmsi) return [14, 234, 255, alpha] as [number, number, number, number];
return [245, 158, 11, alpha] as [number, number, number, number];
},
getPosition: (d) => [d.lon, d.lat] as [number, number],
updateTriggers: { getRadius: [selR, hlR], getLineColor: [alpha, selectedMmsi] },
});
updated = updated.map((l) =>
// eslint-disable-next-line @typescript-eslint/no-explicit-any
(l as any)?.id === 'legacy-halo-overlay' ? haloLyr : l,
);
}
const updated = mercatorLayersRef.current.map((l) =>
// eslint-disable-next-line @typescript-eslint/no-explicit-any
(l as any)?.id === 'alarm-pulse' ? pulseLyr : l,
);
try {
currentOverlay.setProps({ layers: updated } as never);
@ -401,7 +336,7 @@ export function useDeckLayers(
if (alarmRafRef.current) cancelAnimationFrame(alarmRafRef.current);
alarmRafRef.current = 0;
};
}, [projection, alarmMmsiMap, alarmTargets, selectedMmsi, shipHighlightSet, hasAlarms, hasTargetOverlays, legacyOverlayTargets]);
}, [projection, alarmMmsiMap, alarmTargets, selectedMmsi, shipHighlightSet]);
// Globe Deck overlay
useEffect(() => {

파일 보기

@ -244,9 +244,11 @@ export function useGlobeFcFleetOverlay(
: false;
// ── FC lines ──
const fcVisible = overlays.fcLines;
const pairActive = hoveredPairMmsiList.length > 0 || hoveredFleetMmsiList.length > 0;
const fcVisible = overlays.fcLines || pairActive;
// ── Fleet circles ──
const fleetVisible = overlays.fleetCircles;
const fleetActive = hoveredFleetOwnerKeyList.length > 0 || hoveredFleetMmsiList.length > 0;
const fleetVisible = overlays.fleetCircles || fleetActive;
try {
if (map.getLayer('fc-lines-ml')) {
map.setPaintProperty('fc-lines-ml', 'line-opacity', fcVisible ? 0.9 : 0);

파일 보기

@ -241,7 +241,7 @@ export function useGlobePairOverlay(
: false;
// ── Pair lines: 가시성 + 하이라이트 ──
const pairLinesVisible = overlays.pairLines;
const pairLinesVisible = overlays.pairLines || active;
try {
if (map.getLayer('pair-lines-ml')) {
map.setPaintProperty('pair-lines-ml', 'line-opacity', pairLinesVisible ? 0.9 : 0);
@ -265,7 +265,7 @@ export function useGlobePairOverlay(
}
// ── Pair range: 가시성 + 하이라이트 ──
const pairRangeVisible = overlays.pairRange;
const pairRangeVisible = overlays.pairRange || active;
try {
if (map.getLayer('pair-range-ml')) {
map.setPaintProperty('pair-range-ml', 'line-opacity', pairRangeVisible ? 0.85 : 0);

파일 보기

@ -123,7 +123,7 @@ export function useGlobeShipHover(
sog: isFiniteNumber(t.sog) ? t.sog : 0,
shipColor: getGlobeBaseShipColor({
legacy: legacy?.shipCode || null,
signalKindCode: t.signalKindCode || t.shipKindCode || '000027',
sog: isFiniteNumber(t.sog) ? t.sog : null,
}),
iconSize3: clampNumber(0.35 * sizeScale * scale, 0.25, 1.45),
iconSize7: clampNumber(0.45 * sizeScale * scale, 0.3, 1.7),

파일 보기

@ -9,8 +9,11 @@ import type { Map3DSettings, MapProjectionId } from '../types';
import {
ANCHORED_SHIP_ICON_ID,
GLOBE_ICON_HEADING_OFFSET_DEG,
GLOBE_OUTLINE_PERMITTED,
GLOBE_OUTLINE_OTHER,
} from '../constants';
import { isFiniteNumber } from '../lib/setUtils';
import { GLOBE_SHIP_CIRCLE_RADIUS_EXPR } from '../lib/mlExpressions';
import { kickRepaint, onMapStyleReady } from '../lib/mapCore';
import {
isAnchoredShip,
@ -25,41 +28,12 @@ import { clampNumber } from '../lib/geometry';
import { guardedSetVisibility } from '../lib/layerHelpers';
// ── Alarm pulse animation constants ──
// Offset from outline radius so pulse ring never overlaps the outline stroke
const ALARM_PULSE_OFFSET_MIN = 5; // px offset from base circle at rest
const ALARM_PULSE_OFFSET_MAX = 11; // px offset at peak
const ALARM_PULSE_HOVER_OFFSET_MIN = 7;
const ALARM_PULSE_HOVER_OFFSET_MAX = 14;
const ALARM_PULSE_R_MIN = 8;
const ALARM_PULSE_R_MAX = 14;
const ALARM_PULSE_R_HOVER_MIN = 12;
const ALARM_PULSE_R_HOVER_MAX = 18;
const ALARM_PULSE_PERIOD_MS = 1500;
// Base circle radii per zoom (from mlExpressions BASE_VALUES)
const BASE_R_BY_ZOOM = [
[3, 4], [7, 6], [10, 8], [14, 12], [18, 32],
] as const;
/** Build zoom-interpolated radius = base + offset for alarm pulse */
function buildAlarmPulseRadiusExpr(offset: number) {
const stops: unknown[] = ['interpolate', ['linear'], ['zoom']];
for (const [z, base] of BASE_R_BY_ZOOM) {
stops.push(z, base + offset);
}
return stops as never;
}
/** Build zoom-interpolated radius with hover/normal case for alarm pulse */
function buildAlarmPulseRadiusCaseExpr(normalOffset: number, hoverOffset: number) {
const stops: unknown[] = ['interpolate', ['linear'], ['zoom']];
for (const [z, base] of BASE_R_BY_ZOOM) {
stops.push(z, [
'case',
['any', ['==', ['feature-state', 'highlighted'], 1], ['==', ['feature-state', 'selected'], 1]],
base + hoverOffset,
base + normalOffset,
]);
}
return stops as never;
}
/** Globe 모드 선박 아이콘 레이어 (halo, outline, symbol, label, alarm pulse, alarm badge) */
export function useGlobeShipLayers(
mapRef: MutableRefObject<maplibregl.Map | null>,
@ -97,7 +71,7 @@ export function useGlobeShipLayers(
features: shipData.map((t) => {
const legacy = legacyHits?.get(t.mmsi) ?? null;
const alarmKind = alarmMmsiMap?.get(t.mmsi) ?? null;
const baseName = (legacy?.shipNameRoman?.toUpperCase() || legacy?.shipNameCn || t.name?.toUpperCase() || '').trim();
const baseName = legacy?.shipNameCn || legacy?.shipNameRoman || t.name || '';
const labelName = alarmKind ? `[${ALARM_BADGE[alarmKind].label}] ${baseName}` : baseName;
const heading = getDisplayHeading({
cog: t.cog,
@ -110,9 +84,9 @@ export function useGlobeShipLayers(
(isFiniteNumber(t.length) ? t.length : 0) + (isFiniteNumber(t.width) ? t.width : 0) * 3,
50, 420,
);
const baseSizeScale = clampNumber(0.85 + (hull - 50) / 420, 0.82, 1.85);
// 대상 선박은 1.3x 배율 적용
const sizeScale = legacy ? baseSizeScale * 1.3 : baseSizeScale;
const sizeScale = clampNumber(0.85 + (hull - 50) / 420, 0.82, 1.85);
// 상호작용 스케일링 제거 — selected/highlighted는 feature-state로 처리
// hover overlay 레이어가 확대 + z-priority를 담당
const iconSize3 = clampNumber(0.35 * sizeScale, 0.25, 1.3);
const iconSize7 = clampNumber(0.45 * sizeScale, 0.3, 1.45);
const iconSize10 = clampNumber(0.58 * sizeScale, 0.35, 1.8);
@ -132,7 +106,7 @@ export function useGlobeShipLayers(
isAnchored: isAnchored ? 1 : 0,
shipColor: getGlobeBaseShipColor({
legacy: legacy?.shipCode || null,
signalKindCode: t.signalKindCode || t.shipKindCode || '000027',
sog: isFiniteNumber(t.sog) ? t.sog : null,
}),
iconSize3,
iconSize7,
@ -303,7 +277,87 @@ export function useGlobeShipLayers(
['==', ['to-number', ['get', 'alarmed'], 0], 0],
] as unknown as unknown[];
// Ship halo + outline circles — disabled (아이콘 본체만 표시)
if (!map.getLayer(haloId)) {
needReorder = true;
try {
map.addLayer(
{
id: haloId,
type: 'circle',
source: srcId,
layout: {
visibility,
'circle-sort-key': [
'case',
['all', ['==', ['get', 'alarmed'], 1], ['==', ['get', 'permitted'], 1]], 112,
['==', ['get', 'permitted'], 1], 110,
['==', ['get', 'alarmed'], 1], 22,
20,
] as never,
},
paint: {
'circle-radius': GLOBE_SHIP_CIRCLE_RADIUS_EXPR,
'circle-color': ['coalesce', ['get', 'shipColor'], '#64748b'] as never,
'circle-opacity': [
'case',
['==', ['feature-state', 'selected'], 1], 0.38,
['==', ['feature-state', 'highlighted'], 1], 0.34,
['==', ['get', 'permitted'], 1], 0.16,
0.25,
] as never,
},
} as unknown as LayerSpecification,
before,
);
} catch (e) {
console.warn('Ship halo layer add failed:', e);
}
}
if (!map.getLayer(outlineId)) {
needReorder = true;
try {
map.addLayer(
{
id: outlineId,
type: 'circle',
source: srcId,
paint: {
'circle-radius': GLOBE_SHIP_CIRCLE_RADIUS_EXPR,
'circle-color': 'rgba(0,0,0,0)',
'circle-stroke-color': [
'case',
['==', ['feature-state', 'selected'], 1], 'rgba(14,234,255,0.95)',
['==', ['feature-state', 'highlighted'], 1], 'rgba(245,158,11,0.95)',
['==', ['get', 'permitted'], 1], GLOBE_OUTLINE_PERMITTED,
GLOBE_OUTLINE_OTHER,
] as never,
'circle-stroke-width': [
'case',
['==', ['feature-state', 'selected'], 1], 3.4,
['==', ['feature-state', 'highlighted'], 1], 2.7,
['==', ['get', 'permitted'], 1], 1.8,
1.2,
] as never,
'circle-stroke-opacity': 0.85,
},
layout: {
visibility,
'circle-sort-key': [
'case',
['all', ['==', ['get', 'alarmed'], 1], ['==', ['get', 'permitted'], 1]], 122,
['==', ['get', 'permitted'], 1], 120,
['==', ['get', 'alarmed'], 1], 32,
30,
] as never,
},
} as unknown as LayerSpecification,
before,
);
} catch (e) {
console.warn('Ship outline layer add failed:', e);
}
}
// Alarm pulse circle (above outline, below ship icons)
// Uses separate alarm source for stable rendering
@ -318,12 +372,10 @@ export function useGlobeShipLayers(
filter: ['==', ['get', 'alarmed'], 1] as never,
layout: { visibility },
paint: {
'circle-radius': buildAlarmPulseRadiusExpr(ALARM_PULSE_OFFSET_MIN),
'circle-color': 'transparent',
'circle-opacity': 0,
'circle-stroke-color': ['coalesce', ['get', 'alarmBadgeColor'], '#6b7280'] as never,
'circle-stroke-opacity': 0.7,
'circle-stroke-width': 1.5,
'circle-radius': ALARM_PULSE_R_MIN,
'circle-color': ['coalesce', ['get', 'alarmBadgeColor'], '#6b7280'] as never,
'circle-opacity': 0.35,
'circle-stroke-width': 0,
},
} as unknown as LayerSpecification,
before,
@ -341,7 +393,7 @@ export function useGlobeShipLayers(
id: symbolLiteId,
type: 'symbol',
source: srcId,
minzoom: 2,
minzoom: 6.5,
filter: nonPriorityFilter as never,
layout: {
visibility,
@ -356,12 +408,16 @@ export function useGlobeShipLayers(
'interpolate',
['linear'],
['zoom'],
2, 0.5,
5, 0.6,
8, ['max', ['*', ['to-number', ['get', 'iconSize7'], 0.45], 0.806], 0.6],
10, ['max', ['*', ['to-number', ['get', 'iconSize10'], 0.58], 0.936], 0.7],
14, ['*', ['to-number', ['get', 'iconSize14'], 0.85], 1.014],
18, ['*', ['to-number', ['get', 'iconSize18'], 2.5], 1.014],
6.5,
['*', ['to-number', ['get', 'iconSize7'], 0.45], 0.45],
8,
['*', ['to-number', ['get', 'iconSize7'], 0.45], 0.62],
10,
['*', ['to-number', ['get', 'iconSize10'], 0.58], 0.72],
14,
['*', ['to-number', ['get', 'iconSize14'], 0.85], 0.78],
18,
['*', ['to-number', ['get', 'iconSize18'], 2.5], 0.78],
] as unknown as number[],
'icon-allow-overlap': true,
'icon-ignore-placement': true,
@ -381,14 +437,15 @@ export function useGlobeShipLayers(
'interpolate',
['linear'],
['zoom'],
6.5, 0.6,
8, 0.75,
11, 0.9,
14, 1,
6.5,
0.28,
8,
0.45,
11,
0.65,
14,
0.78,
] as never,
'icon-halo-color': 'rgba(0,0,0,0.5)',
'icon-halo-width': 0.8,
'icon-halo-blur': 0.3,
},
} as unknown as LayerSpecification,
before,
@ -424,12 +481,11 @@ export function useGlobeShipLayers(
] as never,
'icon-size': [
'interpolate', ['linear'], ['zoom'],
2, 0.8,
5, 0.9,
7, ['max', ['*', ['to-number', ['get', 'iconSize7'], 0.45], 1.3], 0.9],
10, ['max', ['*', ['to-number', ['get', 'iconSize10'], 0.58], 1.3], 0.9],
14, ['*', ['to-number', ['get', 'iconSize14'], 0.85], 1.3],
18, ['*', ['to-number', ['get', 'iconSize18'], 2.5], 1.3],
3, ['to-number', ['get', 'iconSize3'], 0.35],
7, ['to-number', ['get', 'iconSize7'], 0.45],
10, ['to-number', ['get', 'iconSize10'], 0.58],
14, ['to-number', ['get', 'iconSize14'], 0.85],
18, ['to-number', ['get', 'iconSize18'], 2.5],
] as unknown as number[],
'icon-allow-overlap': true,
'icon-ignore-placement': true,
@ -444,20 +500,13 @@ export function useGlobeShipLayers(
},
paint: {
'icon-color': ['coalesce', ['get', 'shipColor'], '#64748b'] as never,
'icon-opacity': 1,
'icon-halo-color': [
'icon-opacity': [
'case',
['==', ['feature-state', 'selected'], 1], 'rgba(14,234,255,0.95)',
['==', ['feature-state', 'highlighted'], 1], 'rgba(245,158,11,0.95)',
'rgba(0,0,0,0.7)',
['==', ['feature-state', 'selected'], 1], 1,
['==', ['feature-state', 'highlighted'], 1], 0.95,
['==', ['get', 'permitted'], 1], 0.93,
0.9,
] as never,
'icon-halo-width': [
'case',
['==', ['feature-state', 'selected'], 1], 2.5,
['==', ['feature-state', 'highlighted'], 1], 2,
1,
] as never,
'icon-halo-blur': 0.5,
},
} as unknown as LayerSpecification,
before,
@ -467,7 +516,33 @@ export function useGlobeShipLayers(
}
}
// Photo indicator circle — disabled (파란 원 아이콘 제거)
// Photo indicator circle (above ship icons, below labels)
if (!map.getLayer(photoId)) {
needReorder = true;
try {
map.addLayer(
{
id: photoId,
type: 'circle',
source: srcId,
filter: ['==', ['get', 'hasPhoto'], 1] as never,
layout: { visibility: photoVisibility },
paint: {
'circle-radius': [
'interpolate', ['linear'], ['zoom'],
3, 3, 7, 4, 10, 5, 14, 6,
] as never,
'circle-color': 'rgba(0, 188, 212, 0.7)',
'circle-stroke-color': 'rgba(255, 255, 255, 0.8)',
'circle-stroke-width': 1,
},
} as unknown as LayerSpecification,
before,
);
} catch (e) {
console.warn('Ship photo indicator layer add failed:', e);
}
}
const labelFilter = [
'all',
@ -483,13 +558,13 @@ export function useGlobeShipLayers(
id: labelId,
type: 'symbol',
source: srcId,
minzoom: 4,
minzoom: 7,
filter: labelFilter as never,
layout: {
visibility: labelVisibility,
'symbol-placement': 'point',
'text-field': ['get', 'labelName'] as never,
'text-font': ['Noto Sans Regular'],
'text-font': ['Noto Sans Regular', 'Open Sans Regular'],
'text-size': ['interpolate', ['linear'], ['zoom'], 7, 10, 10, 11, 12, 12, 14, 13] as never,
'text-anchor': 'top',
'text-offset': [0, 1.1],
@ -504,9 +579,9 @@ export function useGlobeShipLayers(
['==', ['feature-state', 'highlighted'], 1], 'rgba(245,158,11,0.95)',
'rgba(226,232,240,0.92)',
] as never,
'text-halo-color': 'rgba(0,0,0,0.9)',
'text-halo-width': 0.8,
'text-halo-blur': 0.2,
'text-halo-color': 'rgba(2,6,23,0.85)',
'text-halo-width': 1.2,
'text-halo-blur': 0.8,
},
} as unknown as LayerSpecification,
undefined,
@ -530,7 +605,7 @@ export function useGlobeShipLayers(
layout: {
visibility,
'text-field': ['get', 'alarmBadgeLabel'] as never,
'text-font': ['Noto Sans Regular'],
'text-font': ['Noto Sans Regular', 'Open Sans Regular'],
'text-size': 11,
'text-allow-overlap': true,
'text-ignore-placement': true,
@ -626,16 +701,16 @@ export function useGlobeShipLayers(
return;
}
const t = (Math.sin(Date.now() / ALARM_PULSE_PERIOD_MS * Math.PI * 2) + 1) / 2;
const normalOff = ALARM_PULSE_OFFSET_MIN + t * (ALARM_PULSE_OFFSET_MAX - ALARM_PULSE_OFFSET_MIN);
const hoverOff = ALARM_PULSE_HOVER_OFFSET_MIN + t * (ALARM_PULSE_HOVER_OFFSET_MAX - ALARM_PULSE_HOVER_OFFSET_MIN);
const normalR = ALARM_PULSE_R_MIN + t * (ALARM_PULSE_R_MAX - ALARM_PULSE_R_MIN);
const hoverR = ALARM_PULSE_R_HOVER_MIN + t * (ALARM_PULSE_R_HOVER_MAX - ALARM_PULSE_R_HOVER_MIN);
try {
if (map.getLayer('ships-globe-alarm-pulse')) {
map.setPaintProperty(
'ships-globe-alarm-pulse',
'circle-radius',
buildAlarmPulseRadiusCaseExpr(normalOff, hoverOff),
);
map.setPaintProperty('ships-globe-alarm-pulse', 'circle-stroke-opacity', 0.4 + t * 0.5);
map.setPaintProperty('ships-globe-alarm-pulse', 'circle-radius', [
'case',
['any', ['==', ['feature-state', 'highlighted'], 1], ['==', ['feature-state', 'selected'], 1]],
hoverR,
normalR,
] as never);
}
} catch {
// ignore

파일 보기

@ -163,8 +163,8 @@ export function useMapStyleSettings(
const map = mapRef.current;
const s = settingsRef.current;
if (!map || !s) return;
// Ocean/ENC 모드는 전용 훅에서 별도 처리
if (baseMap === 'ocean' || baseMap === 'enc') return;
// Ocean 모드는 useOceanMapSettings에서 별도 처리
if (baseMap === 'ocean') return;
const stop = onMapStyleReady(map, () => {
applyLabelLanguage(map, s.labelLanguage);

파일 보기

@ -1,55 +0,0 @@
import { useEffect, useMemo, type MutableRefObject } from 'react';
import type maplibregl from 'maplibre-gl';
import type { BaseMapId } from '../types';
import { computeShipLabelColors, type ShipLabelColors } from '../lib/labelColor';
import type { EncMapSettings } from '../../../features/encMap/model/types';
import { DEFAULT_ENC_MAP_SETTINGS } from '../../../features/encMap/model/types';
/** Default colors for non-ENC basemaps (dark background) */
const DARK_BG_COLORS = computeShipLabelColors('#010610');
/**
* Compute ship label colors based on the current basemap background.
* Updates Globe MapLibre text-color paint property on style changes.
*/
export function useShipLabelColor(
mapRef: MutableRefObject<maplibregl.Map | null>,
baseMap: BaseMapId,
mapSyncEpoch: number,
encMapSettings?: EncMapSettings,
): ShipLabelColors {
const bgHex = baseMap === 'enc'
? (encMapSettings ?? DEFAULT_ENC_MAP_SETTINGS).backgroundColor
: '#010610';
const colors = useMemo(() => computeShipLabelColors(bgHex), [bgHex]);
// Update Globe label paint properties when colors change
useEffect(() => {
const map = mapRef.current;
if (!map) return;
const applyGlobeLabelColor = () => {
const labelLayerId = 'ships-globe-label';
try {
if (!map.getLayer(labelLayerId)) return;
// Preserve selected/highlighted colors, only change default
map.setPaintProperty(labelLayerId, 'text-color', [
'case',
['==', ['feature-state', 'selected'], 1], 'rgba(14,234,255,0.95)',
['==', ['feature-state', 'highlighted'], 1], 'rgba(245,158,11,0.95)',
colors.mlDefault,
] as never);
map.setPaintProperty(labelLayerId, 'text-halo-color', colors.mlHalo);
} catch {
// layer may not exist yet
}
};
// Apply immediately and also on next style ready
applyGlobeLabelColor();
}, [colors, mapSyncEpoch]);
return baseMap === 'enc' ? colors : DARK_BG_COLORS;
}

파일 보기

@ -101,7 +101,7 @@ const LAYER_SPECS: NativeLayerSpec[] = [
'symbol-placement': 'line',
'text-field': ['get', 'name'],
'text-size': ['interpolate', ['linear'], ['zoom'], 4, 9, 8, 11, 12, 13],
'text-font': ['Noto Sans Regular'],
'text-font': ['Noto Sans Regular', 'Open Sans Regular'],
'text-allow-overlap': false,
'text-padding': 8,
'text-rotation-alignment': 'map',
@ -123,7 +123,7 @@ const LAYER_SPECS: NativeLayerSpec[] = [
'symbol-placement': 'line',
'text-field': ['get', 'name'],
'text-size': ['interpolate', ['linear'], ['zoom'], 2, 14, 6, 17, 10, 20],
'text-font': ['Noto Sans Bold'],
'text-font': ['Noto Sans Bold', 'Open Sans Bold'],
'text-allow-overlap': true,
'text-padding': 2,
'text-rotation-alignment': 'map',

파일 보기

@ -231,7 +231,7 @@ export function useZonesLayer(
'symbol-placement': 'point',
'text-field': zoneLabelExpr as never,
'text-size': 11,
'text-font': ['Noto Sans Regular'],
'text-font': ['Noto Sans Regular', 'Open Sans Regular'],
'text-anchor': 'top',
'text-offset': [0, 0.35],
'text-allow-overlap': false,

파일 보기

@ -224,7 +224,7 @@ export function injectOceanBathymetryLayers(style: StyleSpecification, maptilerK
layout: {
'symbol-placement': 'line',
'text-field': depthLabel,
'text-font': ['Noto Sans Regular'],
'text-font': ['Noto Sans Regular', 'Open Sans Regular'],
'text-size': ['interpolate', ['linear'], ['zoom'], 6, 10, 9, 12],
'text-allow-overlap': false,
'text-padding': 4,
@ -249,7 +249,7 @@ export function injectOceanBathymetryLayers(style: StyleSpecification, maptilerK
layout: {
'symbol-placement': 'line',
'text-field': depthLabel,
'text-font': ['Noto Sans Regular'],
'text-font': ['Noto Sans Regular', 'Open Sans Regular'],
'text-size': ['interpolate', ['linear'], ['zoom'], 9, 12, 11, 14, 13, 16],
'text-allow-overlap': false,
'text-padding': 4,
@ -272,7 +272,7 @@ export function injectOceanBathymetryLayers(style: StyleSpecification, maptilerK
filter: ['has', 'name'] as unknown as unknown[],
layout: {
'text-field': ['get', 'name'] as unknown as unknown[],
'text-font': ['Noto Sans Regular'],
'text-font': ['Noto Sans Italic', 'Noto Sans Regular', 'Open Sans Italic', 'Open Sans Regular'],
'text-size': ['interpolate', ['linear'], ['zoom'], 6, 10, 8, 12, 10, 13, 12, 14],
'text-allow-overlap': false,
'text-anchor': 'center',
@ -394,10 +394,6 @@ export async function resolveMapStyle(baseMap: BaseMapId, signal: AbortSignal):
const { resolveOceanStyle } = await import('../../../features/oceanMap/lib/resolveOceanStyle');
return resolveOceanStyle(signal);
}
if (baseMap === 'enc') {
const { fetchEncStyle } = await import('../../../features/encMap/lib/encStyle');
return fetchEncStyle(signal);
}
// 레거시 베이스맵 비활성 — 향후 위성/라이트 테마 등 추가 시 재활용
// if (baseMap === 'legacy') return '/map/styles/carto-dark.json';
void baseMap;

파일 보기

@ -8,14 +8,19 @@ import type { FleetCircle, PairLink } from '../../../features/legacyDashboard/mo
import type { MapToggleState } from '../../../features/mapToggles/MapToggles';
import type { DashSeg, PairRangeCircle } from '../types';
import {
FLAT_OTHER_SHIP_SIZE,
FLAT_TARGET_SHIP_SIZE,
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,
@ -33,18 +38,8 @@ import {
FLEET_RANGE_LINE_DECK_HL,
FLEET_RANGE_FILL_DECK_HL,
} from '../constants';
import { getDisplayHeading } from './shipUtils';
import {
getShipIconSpec,
getTargetShipIconSpec,
getShipIconAngle,
SPEED_THRESHOLD_KN,
} from '../../../shared/lib/map/shipKind';
/** 64×64 white ring SVG — mask:true로 getColor 틴트 적용 */
const ALARM_RING_ICON_URL = `data:image/svg+xml,${encodeURIComponent(
'<svg xmlns="http://www.w3.org/2000/svg" width="64" height="64"><circle cx="32" cy="32" r="27" fill="none" stroke="white" stroke-width="5"/></svg>',
)}`;
import { getDisplayHeading, getShipColor } from './shipUtils';
import { getCachedShipIcon } from './shipIconCache';
/* ── 공통 콜백 인터페이스 ─────────────────────────────── */
@ -69,6 +64,7 @@ interface DeckSelectCallbacks {
export interface MercatorDeckLayerContext extends DeckHoverCallbacks, DeckSelectCallbacks {
shipLayerData: AisTarget[];
shipOverlayLayerData: AisTarget[];
legacyTargetsOrdered: AisTarget[];
legacyOverlayTargets: AisTarget[];
legacyHits: Map<number, LegacyVesselInfo> | null | undefined;
@ -91,7 +87,6 @@ export interface MercatorDeckLayerContext extends DeckHoverCallbacks, DeckSelect
alarmPulseHoverRadius?: number;
shipPhotoTargets?: AisTarget[];
onClickShipPhoto?: (mmsi: number) => void;
shipLabelColors?: import('./labelColor').ShipLabelColors;
}
export function buildMercatorDeckLayers(ctx: MercatorDeckLayerContext): unknown[] {
@ -106,6 +101,10 @@ export function buildMercatorDeckLayers(ctx: MercatorDeckLayerContext): unknown[
if (isTargetShip(t.mmsi)) shipTargetData.push(t);
else shipOtherData.push(t);
}
const shipOverlayOtherData: AisTarget[] = [];
for (const t of ctx.shipOverlayLayerData) {
if (!isTargetShip(t.mmsi)) shipOverlayOtherData.push(t);
}
/* ─ density ─ */
if (ctx.showDensity) {
@ -319,6 +318,26 @@ export function buildMercatorDeckLayers(ctx: MercatorDeckLayerContext): unknown[
};
if (shipOtherData.length > 0) {
layers.push(
new ScatterplotLayer<AisTarget>({
id: 'ships-other-halo',
data: shipOtherData,
pickable: false,
billboard: false,
parameters: overlayParams,
getPosition: (d) => [d.lon, d.lat] as [number, number],
radiusUnits: 'pixels',
getRadius: 10,
getFillColor: (d) => getShipColor(d, null, null, EMPTY_MMSI_SET).slice(0, 3).concat(40) as unknown as [number, number, number, number],
getLineColor: (d) => {
const c = getShipColor(d, null, null, EMPTY_MMSI_SET);
return [c[0], c[1], c[2], 100] as [number, number, number, number];
},
stroked: true,
lineWidthUnits: 'pixels',
getLineWidth: 1,
}),
);
layers.push(
new IconLayer<AisTarget>({
id: 'ships-other',
@ -326,11 +345,14 @@ export function buildMercatorDeckLayers(ctx: MercatorDeckLayerContext): unknown[
pickable: true,
billboard: false,
parameters: overlayParams,
getIcon: (d) => getShipIconSpec(d.signalKindCode ?? d.shipKindCode, d.sog),
iconAtlas: getCachedShipIcon(),
iconMapping: SHIP_ICON_MAPPING,
getIcon: () => 'ship',
getPosition: (d) => [d.lon, d.lat] as [number, number],
getAngle: (d) => getShipIconAngle(d.signalKindCode ?? d.shipKindCode, d.cog),
getAngle: (d) => -getDisplayHeading({ cog: d.cog, heading: d.heading }),
sizeUnits: 'pixels',
getSize: FLAT_OTHER_SHIP_SIZE,
getSize: () => FLAT_SHIP_ICON_SIZE,
getColor: (d) => getShipColor(d, null, null, EMPTY_MMSI_SET),
onHover: shipOnHover,
onClick: shipOnClick,
alphaCutoff: 0.05,
@ -338,6 +360,31 @@ export function buildMercatorDeckLayers(ctx: MercatorDeckLayerContext): unknown[
);
}
if (shipOverlayOtherData.length > 0) {
layers.push(
new IconLayer<AisTarget>({
id: 'ships-overlay-other',
data: shipOverlayOtherData,
pickable: false,
billboard: false,
parameters: overlayParams,
iconAtlas: getCachedShipIcon(),
iconMapping: SHIP_ICON_MAPPING,
getIcon: () => 'ship',
getPosition: (d) => [d.lon, d.lat] as [number, number],
getAngle: (d) => -getDisplayHeading({ cog: d.cog, heading: d.heading }),
sizeUnits: 'pixels',
getSize: (d) => {
if (ctx.selectedMmsi != null && d.mmsi === ctx.selectedMmsi) return FLAT_SHIP_ICON_SIZE_SELECTED;
if (ctx.shipHighlightSet.has(d.mmsi)) return FLAT_SHIP_ICON_SIZE_HIGHLIGHTED;
return 0;
},
getColor: (d) => getShipColor(d, ctx.selectedMmsi, null, ctx.shipHighlightSet),
alphaCutoff: 0.05,
}),
);
}
if (ctx.legacyTargetsOrdered.length > 0) {
layers.push(
new ScatterplotLayer<AisTarget>({
@ -366,14 +413,14 @@ export function buildMercatorDeckLayers(ctx: MercatorDeckLayerContext): unknown[
pickable: true,
billboard: false,
parameters: overlayParams,
getIcon: (d) => getTargetShipIconSpec(ctx.legacyHits?.get(d.mmsi)?.shipCode ?? null, d.sog),
iconAtlas: getCachedShipIcon(),
iconMapping: SHIP_ICON_MAPPING,
getIcon: () => 'ship',
getPosition: (d) => [d.lon, d.lat] as [number, number],
getAngle: (d) => {
const isMoving = Number.isFinite(d.sog) && (d.sog as number) > SPEED_THRESHOLD_KN;
return isMoving ? -getDisplayHeading({ cog: d.cog, heading: d.heading }) : 0;
},
getAngle: (d) => -getDisplayHeading({ cog: d.cog, heading: d.heading }),
sizeUnits: 'pixels',
getSize: FLAT_TARGET_SHIP_SIZE,
getSize: () => FLAT_SHIP_ICON_SIZE,
getColor: (d) => getShipColor(d, null, ctx.legacyHits?.get(d.mmsi)?.shipCode ?? null, EMPTY_MMSI_SET),
onHover: shipOnHover,
onClick: shipOnClick,
alphaCutoff: 0.05,
@ -382,41 +429,29 @@ export function buildMercatorDeckLayers(ctx: MercatorDeckLayerContext): unknown[
}
}
/* ─ interactive overlays (only when parent overlay is enabled) ─ */
if (ctx.overlays.pairRange && ctx.pairRangesInteractive.length > 0) {
/* ─ interactive overlays ─ */
if (ctx.pairRangesInteractive.length > 0) {
layers.push(new ScatterplotLayer<PairRangeCircle>({ id: 'pair-range-overlay', data: ctx.pairRangesInteractive, pickable: false, billboard: false, parameters: overlayParams, filled: false, stroked: true, radiusUnits: 'meters', getRadius: (d) => d.radiusNm * 1852, radiusMinPixels: 10, lineWidthUnits: 'pixels', getLineWidth: () => 2.8, getLineColor: (d) => (d.warn ? PAIR_RANGE_WARN_DECK_HL : PAIR_RANGE_NORMAL_DECK_HL), getPosition: (d) => d.center }));
}
if (ctx.overlays.pairLines && ctx.pairLinksInteractive.length > 0) {
if (ctx.pairLinksInteractive.length > 0) {
layers.push(new LineLayer<PairLink>({ id: 'pair-lines-overlay', data: ctx.pairLinksInteractive, pickable: false, parameters: overlayParams, getSourcePosition: (d) => d.from, getTargetPosition: (d) => d.to, getColor: (d) => (d.warn ? PAIR_LINE_WARN_DECK_HL : PAIR_LINE_NORMAL_DECK_HL), getWidth: () => 4.5, widthUnits: 'pixels' }));
}
if (ctx.overlays.fcLines && ctx.fcLinesInteractive.length > 0) {
if (ctx.fcLinesInteractive.length > 0) {
layers.push(new LineLayer<DashSeg>({ id: 'fc-lines-overlay', data: ctx.fcLinesInteractive, pickable: false, parameters: overlayParams, getSourcePosition: (d) => d.from, getTargetPosition: (d) => d.to, getColor: (d) => (d.suspicious ? FC_LINE_SUSPICIOUS_DECK_HL : FC_LINE_NORMAL_DECK_HL), getWidth: () => 3.2, widthUnits: 'pixels' }));
}
if (ctx.overlays.fleetCircles && ctx.fleetCirclesInteractive.length > 0) {
if (ctx.fleetCirclesInteractive.length > 0) {
layers.push(new ScatterplotLayer<FleetCircle>({ id: 'fleet-circles-overlay-fill', data: ctx.fleetCirclesInteractive, pickable: false, billboard: false, parameters: overlayParams, filled: true, stroked: false, radiusUnits: 'meters', getRadius: (d) => d.radiusNm * 1852, getFillColor: () => FLEET_RANGE_FILL_DECK_HL }));
layers.push(new ScatterplotLayer<FleetCircle>({ id: 'fleet-circles-overlay', data: ctx.fleetCirclesInteractive, pickable: false, billboard: false, parameters: overlayParams, filled: false, stroked: true, radiusUnits: 'meters', getRadius: (d) => d.radiusNm * 1852, lineWidthUnits: 'pixels', getLineWidth: () => 3.0, getLineColor: () => FLEET_RANGE_LINE_DECK_HL, getPosition: (d) => d.center }));
}
/* ─ legacy overlay (highlight/selected — breathing ring, no icon enlargement) ─ */
/* ─ legacy overlay (highlight/selected) ─ */
if (ctx.showShips && ctx.legacyOverlayTargets.length > 0) {
layers.push(new ScatterplotLayer<AisTarget>({
id: 'legacy-halo-overlay',
data: ctx.legacyOverlayTargets,
pickable: false,
billboard: false,
parameters: overlayParams,
filled: false,
stroked: true,
radiusUnits: 'pixels',
getRadius: (d) => (ctx.selectedMmsi && d.mmsi === ctx.selectedMmsi ? 18 : 16),
lineWidthUnits: 'pixels',
getLineWidth: 2.5,
getLineColor: (d) => {
if (ctx.selectedMmsi && d.mmsi === ctx.selectedMmsi) return [14, 234, 255, 200];
return [245, 158, 11, 190];
},
getPosition: (d) => [d.lon, d.lat] as [number, number],
}));
layers.push(new ScatterplotLayer<AisTarget>({ id: 'legacy-halo-overlay', data: ctx.legacyOverlayTargets, pickable: false, billboard: false, parameters: overlayParams, filled: false, stroked: true, radiusUnits: 'pixels', getRadius: (d) => { if (ctx.selectedMmsi && d.mmsi === ctx.selectedMmsi) return FLAT_LEGACY_HALO_RADIUS_SELECTED; return FLAT_LEGACY_HALO_RADIUS_HIGHLIGHTED; }, lineWidthUnits: 'pixels', getLineWidth: (d) => (ctx.selectedMmsi && d.mmsi === ctx.selectedMmsi ? 2.5 : 2.2), getLineColor: (d) => { if (ctx.selectedMmsi && d.mmsi === ctx.selectedMmsi) return HALO_OUTLINE_COLOR_SELECTED; if (ctx.shipHighlightSet.has(d.mmsi)) return HALO_OUTLINE_COLOR_HIGHLIGHTED; return HALO_OUTLINE_COLOR; }, getPosition: (d) => [d.lon, d.lat] as [number, number] }));
}
if (ctx.showShips && ctx.shipOverlayLayerData.filter((t) => ctx.legacyHits?.has(t.mmsi)).length > 0) {
const shipOverlayTargetData2 = ctx.shipOverlayLayerData.filter((t) => ctx.legacyHits?.has(t.mmsi));
layers.push(new IconLayer<AisTarget>({ id: 'ships-overlay-target', data: shipOverlayTargetData2, pickable: false, billboard: false, parameters: overlayParams, iconAtlas: getCachedShipIcon(), iconMapping: SHIP_ICON_MAPPING, getIcon: () => 'ship', getPosition: (d) => [d.lon, d.lat] as [number, number], getAngle: (d) => -getDisplayHeading({ cog: d.cog, heading: d.heading }), sizeUnits: 'pixels', getSize: (d) => { if (ctx.selectedMmsi != null && d.mmsi === ctx.selectedMmsi) return FLAT_SHIP_ICON_SIZE_SELECTED; if (ctx.shipHighlightSet.has(d.mmsi)) return FLAT_SHIP_ICON_SIZE_HIGHLIGHTED; return 0; }, getColor: (d) => { if (!ctx.shipHighlightSet.has(d.mmsi) && !(ctx.selectedMmsi != null && d.mmsi === ctx.selectedMmsi)) return [0, 0, 0, 0]; return getShipColor(d, ctx.selectedMmsi, ctx.legacyHits?.get(d.mmsi)?.shipCode ?? null, ctx.shipHighlightSet); } }));
}
/* ─ ship name labels (Mercator) ─ */
@ -437,7 +472,7 @@ export function buildMercatorDeckLayers(ctx: MercatorDeckLayerContext): unknown[
billboard: true,
getText: (d) => {
const legacy = ctx.legacyHits?.get(d.mmsi);
const baseName = (legacy?.shipNameRoman?.toUpperCase() || legacy?.shipNameCn || d.name?.toUpperCase() || '').trim();
const baseName = (legacy?.shipNameCn || legacy?.shipNameRoman || d.name || '').trim();
if (!baseName) return '';
const alarmKind = ctx.alarmMmsiMap?.get(d.mmsi) ?? null;
return alarmKind ? `[${ALARM_BADGE[alarmKind].label}] ${baseName}` : baseName;
@ -446,7 +481,7 @@ export function buildMercatorDeckLayers(ctx: MercatorDeckLayerContext): unknown[
getColor: (d) => {
if (ctx.selectedMmsi != null && d.mmsi === ctx.selectedMmsi) return [14, 234, 255, 242];
if (ctx.shipHighlightSet.has(d.mmsi)) return [245, 158, 11, 242];
return ctx.shipLabelColors?.deckDefault ?? [226, 232, 240, 234];
return [226, 232, 240, 234];
},
getSize: 11,
sizeUnits: 'pixels',
@ -454,40 +489,39 @@ export function buildMercatorDeckLayers(ctx: MercatorDeckLayerContext): unknown[
characterSet: 'auto',
getPixelOffset: [0, 16],
getTextAnchor: 'middle',
outlineWidth: 1,
outlineColor: ctx.shipLabelColors?.deckOutline ?? [0, 0, 0, 230],
outlineWidth: 2,
outlineColor: [2, 6, 23, 217],
}),
);
}
}
/* ─ alarm pulse (IconLayer + SVG ring) + badge ─ */
/* ─ alarm pulse + badge ─ */
const alarmTargets = ctx.alarmTargets ?? [];
const alarmMap = ctx.alarmMmsiMap;
if (ctx.showShips && alarmMap && alarmMap.size > 0 && alarmTargets.length > 0) {
const pulseSize = ctx.alarmPulseRadius ?? 40;
const pulseHoverSize = ctx.alarmPulseHoverRadius ?? 48;
const pulseR = ctx.alarmPulseRadius ?? 8;
const pulseHR = ctx.alarmPulseHoverRadius ?? 12;
layers.push(
new IconLayer<AisTarget>({
new ScatterplotLayer<AisTarget>({
id: 'alarm-pulse',
data: alarmTargets,
pickable: false,
billboard: true,
billboard: false,
parameters: overlayParams,
iconAtlas: ALARM_RING_ICON_URL,
iconMapping: { ring: { x: 0, y: 0, width: 64, height: 64, mask: true } },
getIcon: () => 'ring',
sizeUnits: 'pixels',
getSize: (d) => {
filled: true,
stroked: false,
radiusUnits: 'pixels',
getRadius: (d) => {
const isHover = (ctx.selectedMmsi != null && d.mmsi === ctx.selectedMmsi) || ctx.shipHighlightSet.has(d.mmsi);
return isHover ? pulseHoverSize : pulseSize;
return isHover ? pulseHR : pulseR;
},
getColor: (d) => {
getFillColor: (d) => {
const kind = alarmMap.get(d.mmsi);
return kind ? ALARM_BADGE[kind].rgba : [107, 114, 128, 200] as [number, number, number, number];
return kind ? ALARM_BADGE[kind].rgba : [107, 114, 128, 90] as [number, number, number, number];
},
getPosition: (d) => [d.lon, d.lat] as [number, number],
updateTriggers: { getSize: [pulseSize, pulseHoverSize, ctx.selectedMmsi, ctx.shipHighlightSet] },
updateTriggers: { getRadius: [pulseR, pulseHR, ctx.selectedMmsi, ctx.shipHighlightSet] },
}),
);
layers.push(
@ -517,7 +551,30 @@ export function buildMercatorDeckLayers(ctx: MercatorDeckLayerContext): unknown[
);
}
/* ─ ship photo indicator — disabled (파란 원 아이콘 제거) ─ */
/* ─ ship photo indicator (사진 유무 표시) ─ */
const photoTargets = ctx.shipPhotoTargets ?? [];
if (ctx.showShips && photoTargets.length > 0) {
layers.push(
new ScatterplotLayer<AisTarget>({
id: 'ship-photo-indicator',
data: photoTargets,
pickable: true,
billboard: false,
filled: true,
stroked: true,
radiusUnits: 'pixels',
getRadius: 5,
getFillColor: [0, 188, 212, 180],
getLineColor: [255, 255, 255, 200],
lineWidthUnits: 'pixels',
getLineWidth: 1,
getPosition: (d) => [d.lon, d.lat] as [number, number],
onClick: (info: PickingInfo) => {
if (info.object) ctx.onClickShipPhoto?.((info.object as AisTarget).mmsi);
},
}),
);
}
return layers;
}

파일 보기

@ -1,59 +0,0 @@
/**
* Compute a readable ship label color based on the map background luminance.
* Returns RGBA arrays for Deck.gl and CSS string for MapLibre.
*/
function hexToRgb(hex: string): [number, number, number] {
const h = hex.replace('#', '');
return [
parseInt(h.slice(0, 2), 16),
parseInt(h.slice(2, 4), 16),
parseInt(h.slice(4, 6), 16),
];
}
/** Relative luminance (WCAG) */
function luminance(r: number, g: number, b: number): number {
const [rs, gs, bs] = [r, g, b].map((c) => {
const s = c / 255;
return s <= 0.03928 ? s / 12.92 : ((s + 0.055) / 1.055) ** 2.4;
});
return 0.2126 * rs + 0.7152 * gs + 0.0722 * bs;
}
export interface ShipLabelColors {
/** Deck.gl TextLayer default getColor [R,G,B,A] */
deckDefault: [number, number, number, number];
/** MapLibre text-color CSS string */
mlDefault: string;
/** MapLibre text-halo-color CSS string */
mlHalo: string;
/** Deck.gl outlineColor [R,G,B,A] */
deckOutline: [number, number, number, number];
}
/**
* Given a background hex color, compute label colors that contrast well.
*/
export function computeShipLabelColors(bgHex: string): ShipLabelColors {
const [r, g, b] = hexToRgb(bgHex);
const lum = luminance(r, g, b);
// Light background (lum > 0.4): dark labels with light halo
// Dark background (lum <= 0.4): light labels with dark halo
if (lum > 0.4) {
return {
deckDefault: [30, 30, 40, 234],
mlDefault: 'rgba(30,30,40,0.92)',
mlHalo: 'rgba(255,255,255,0.85)',
deckOutline: [255, 255, 255, 210],
};
}
return {
deckDefault: [226, 232, 240, 234],
mlDefault: 'rgba(226,232,240,0.92)',
mlHalo: 'rgba(0,0,0,0.9)',
deckOutline: [0, 0, 0, 230],
};
}

파일 보기

@ -0,0 +1,30 @@
/**
* Ship SVG fetch하여 data URL로 .
* Deck.gl IconLayer가 iconAtlas URL을 fetch하지
* data URL을 .
*/
const SHIP_SVG_URL = '/assets/ship.svg';
let _cachedDataUrl: string | null = null;
let _promise: Promise<string> | null = null;
function preloadShipIcon(): Promise<string> {
if (_cachedDataUrl) return Promise.resolve(_cachedDataUrl);
if (_promise) return _promise;
_promise = fetch(SHIP_SVG_URL)
.then((res) => res.text())
.then((svg) => {
_cachedDataUrl = `data:image/svg+xml;base64,${btoa(svg)}`;
return _cachedDataUrl;
})
.catch(() => SHIP_SVG_URL);
return _promise;
}
/** 캐시된 data URL 또는 폴백 URL 반환 */
export function getCachedShipIcon(): string {
return _cachedDataUrl ?? SHIP_SVG_URL;
}
// 모듈 임포트 시 즉시 로드 시작
preloadShipIcon();

파일 보기

@ -1,10 +1,12 @@
import type { AisTarget } from '../../../entities/aisTarget/model/types';
import type { LegacyVesselInfo } from '../../../entities/legacyVessel/model/types';
import { rgbToHex } from '../../../shared/lib/map/palette';
import { SHIP_KIND_COLORS } from '../../../shared/lib/map/shipKind';
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';
@ -51,21 +53,44 @@ export function lightenColor(rgb: [number, number, number], ratio = 0.32) {
export function getGlobeBaseShipColor({
legacy,
signalKindCode,
sog,
}: {
legacy: string | null;
signalKindCode?: string;
sog: number | null;
}) {
// 대상 선박: legacy code 색상 (밝게)
if (legacy) {
const rgb = LEGACY_CODE_COLORS[legacy];
if (rgb) return rgbToHex(lightenColor(rgb, 0.38));
}
// 기타 AIS: signalKindCode → 선종별 색상
const kindColor = SHIP_KIND_COLORS[signalKindCode || '000027'];
if (kindColor) return kindColor;
return '#607D8B';
// Keep alpha control in icon-opacity only to avoid double-multiplying transparency.
if (!isFiniteNumber(sog)) return '#64748b';
if (sog >= 10) return '#94a3b8';
if (sog >= 1) return '#64748b';
return '#475569';
}
export function getShipColor(
t: AisTarget,
selectedMmsi: number | null,
legacyShipCode: string | null,
highlightedMmsis: Set<number>,
): [number, number, number, number] {
if (selectedMmsi && t.mmsi === selectedMmsi) {
return [MAP_SELECTED_SHIP_RGB[0], MAP_SELECTED_SHIP_RGB[1], MAP_SELECTED_SHIP_RGB[2], 255];
}
if (highlightedMmsis.has(t.mmsi)) {
return [MAP_HIGHLIGHT_SHIP_RGB[0], MAP_HIGHLIGHT_SHIP_RGB[1], MAP_HIGHLIGHT_SHIP_RGB[2], 235];
}
if (legacyShipCode) {
const rgb = LEGACY_CODE_COLORS[legacyShipCode];
if (rgb) return [rgb[0], rgb[1], rgb[2], 235];
return [245, 158, 11, 235];
}
if (!isFiniteNumber(t.sog)) return [MAP_DEFAULT_SHIP_RGB[0], MAP_DEFAULT_SHIP_RGB[1], MAP_DEFAULT_SHIP_RGB[2], 175];
if (t.sog >= 10) return [148, 163, 184, 215];
if (t.sog >= 1) return [MAP_DEFAULT_SHIP_RGB[0], MAP_DEFAULT_SHIP_RGB[1], MAP_DEFAULT_SHIP_RGB[2], 210];
return [71, 85, 105, 200];
}
export function buildGlobeShipFeature(
@ -83,14 +108,11 @@ export function buildGlobeShipFeature(
mmsi: t.mmsi,
heading: getDisplayHeading({ cog: t.cog, heading: t.heading, offset }),
anchored,
color: getGlobeBaseShipColor({
legacy: legacy?.shipCode ?? null,
signalKindCode: t.signalKindCode || t.shipKindCode || '000027',
}),
color: getGlobeBaseShipColor({ legacy: legacy?.shipCode ?? null, sog: t.sog ?? null }),
selected: isSelected,
highlighted: isHighlighted,
permitted: legacy ? 1 : 0,
labelName: (legacy?.shipNameRoman?.toUpperCase() || legacy?.shipNameCn || t.name?.toUpperCase() || '').trim(),
labelName: (t.name || '').trim() || legacy?.shipNameCn || legacy?.shipNameRoman || '',
legacyTag: legacy ? `${legacy.permitNo} (${legacy.shipCode})` : '',
};
}

파일 보기

@ -23,7 +23,7 @@ export function getTargetName(
const legacy = legacyHits?.get(mmsi);
const target = targetByMmsi.get(mmsi);
return (
(legacy?.shipNameRoman?.toUpperCase() || legacy?.shipNameCn || target?.name?.toUpperCase() || '').trim() || `MMSI ${mmsi}`
(target?.name || '').trim() || legacy?.shipNameCn || legacy?.shipNameRoman || `MMSI ${mmsi}`
);
}

파일 보기

@ -7,7 +7,6 @@ import type { MapToggleState } from '../../features/mapToggles/MapToggles';
import type { FcLink, FleetCircle, LegacyAlarmKind, PairLink } from '../../features/legacyDashboard/model/types';
import type { MapStyleSettings } from '../../features/mapSettings/types';
import type { OceanMapSettings } from '../../features/oceanMap/model/types';
import type { EncMapSettings } from '../../features/encMap/model/types';
export type Map3DSettings = {
showSeamark: boolean;
@ -15,7 +14,7 @@ export type Map3DSettings = {
showDensity: boolean;
};
export type BaseMapId = 'enhanced' | 'enc' | 'ocean' | 'legacy';
export type BaseMapId = 'enhanced' | 'ocean' | 'legacy';
export type MapProjectionId = 'mercator' | 'globe';
export interface MapViewState {
@ -67,10 +66,10 @@ export interface Map3DProps {
onViewStateChange?: (view: MapViewState) => void;
onGlobeShipsReady?: (ready: boolean) => void;
activeTrack?: ActiveTrack | null;
trackContextMenu?: { x: number; y: number; mmsi: number; vesselName: string; isPermitted: boolean } | null;
trackContextMenu?: { x: number; y: number; mmsi: number; vesselName: string } | null;
onRequestTrack?: (mmsi: number, minutes: number) => void;
onCloseTrackMenu?: () => void;
onOpenTrackMenu?: (info: { x: number; y: number; mmsi: number; vesselName: string; isPermitted: boolean }) => void;
onOpenTrackMenu?: (info: { x: number; y: number; mmsi: number; vesselName: string }) => void;
/** MMSI → 가장 높은 우선순위 경고 종류. filteredAlarms 기반. */
alarmMmsiMap?: Map<number, LegacyAlarmKind>;
/** 사진 있는 선박 클릭 시 콜백 (사진 표시자 or 선박 아이콘) */
@ -79,8 +78,6 @@ export interface Map3DProps {
freeCamera?: boolean;
/** Ocean 지도 전용 설정 */
oceanMapSettings?: OceanMapSettings;
/** ENC 전자해도 전용 설정 */
encMapSettings?: EncMapSettings;
}
export type DashSeg = {

파일 보기

@ -15,7 +15,6 @@ interface Props {
onToggleTheme?: () => void;
isSidebarOpen?: boolean;
onMenuToggle?: () => void;
onOpenMultiTrack?: () => void;
}
function StatChips({ total, fishing, transit, pairLinks, alarms }: Pick<Props, "total" | "fishing" | "transit" | "pairLinks" | "alarms">) {
@ -40,7 +39,7 @@ function StatChips({ total, fishing, transit, pairLinks, alarms }: Pick<Props, "
);
}
export function Topbar({ total, fishing, transit, pairLinks, alarms, clock, adminMode, onLogoClick, userName, onLogout, theme, onToggleTheme, isSidebarOpen, onMenuToggle, onOpenMultiTrack }: Props) {
export function Topbar({ total, fishing, transit, pairLinks, alarms, clock, adminMode, onLogoClick, userName, onLogout, theme, onToggleTheme, isSidebarOpen, onMenuToggle }: Props) {
const [isStatsOpen, setIsStatsOpen] = useState(false);
return (
@ -84,15 +83,6 @@ export function Topbar({ total, fishing, transit, pairLinks, alarms, clock, admi
{/* 데스크톱: 인라인 통계 */}
<div className="ml-auto hidden items-center gap-3.5 md:flex">
<StatChips total={total} fishing={fishing} transit={transit} pairLinks={pairLinks} alarms={alarms} />
{onOpenMultiTrack && (
<button
className="cursor-pointer whitespace-nowrap rounded-md border border-blue-500/50 bg-blue-600/20 px-2.5 py-1 text-[11px] font-semibold text-blue-300 transition-all duration-150 hover:border-blue-400 hover:bg-blue-600/30 hover:text-blue-200"
onClick={onOpenMultiTrack}
title="다중 선박 항적 조회"
>
</button>
)}
</div>
{/* 항상 표시: 시계 + 테마 + 사용자 */}

파일 보기

@ -1,10 +1,6 @@
import { useCallback, useMemo, useState } from 'react';
import { useCallback, useEffect, useMemo, useRef, useState, type PointerEvent as ReactPointerEvent } from 'react';
import { useTrackPlaybackStore, TRACK_PLAYBACK_SPEED_OPTIONS } from '../../features/trackReplay/stores/trackPlaybackStore';
import { useTrackQueryStore } from '../../features/trackReplay/stores/trackQueryStore';
import { exportTrackCsv } from '../../features/trackReplay/lib/csvExport';
import { SHIP_KIND_COLORS } from '../../shared/lib/map/shipKind';
import type { ProcessedTrack } from '../../features/trackReplay/model/track.types';
import { MAX_QUERY_DAYS } from '../../features/vesselSelect/model/types';
function formatDateTime(ms: number): string {
if (!Number.isFinite(ms) || ms <= 0) return '--';
@ -15,127 +11,146 @@ function formatDateTime(ms: number): string {
)}:${pad(date.getSeconds())}`;
}
function toDateTimeLocalKST(ms: number): string {
if (!Number.isFinite(ms) || ms <= 0) return '';
const kstDate = new Date(ms + 9 * 3600_000);
return kstDate.toISOString().slice(0, 16);
}
export function GlobalTrackReplayPanel() {
const PANEL_WIDTH = 420;
const PANEL_MARGIN = 12;
const PANEL_DEFAULT_TOP = 16;
const PANEL_RIGHT_RESERVED = 520;
function fromDateTimeLocalKST(value: string): string {
return `${value}:00+09:00`;
}
const panelRef = useRef<HTMLDivElement | null>(null);
const dragRef = useRef<{ pointerId: number; startX: number; startY: number; originX: number; originY: number } | null>(
null,
);
const [isDragging, setIsDragging] = useState(false);
const inputStyle: React.CSSProperties = {
flex: 1,
fontSize: 11,
padding: '3px 6px',
borderRadius: 4,
border: '1px solid rgba(148,163,184,0.35)',
background: 'rgba(30,41,59,0.8)',
color: '#e2e8f0',
colorScheme: 'dark',
};
const clampPosition = useCallback(
(x: number, y: number) => {
if (typeof window === 'undefined') return { x, y };
const viewportWidth = window.innerWidth;
const viewportHeight = window.innerHeight;
const panelHeight = panelRef.current?.offsetHeight ?? 360;
return {
x: Math.min(Math.max(PANEL_MARGIN, x), Math.max(PANEL_MARGIN, viewportWidth - PANEL_WIDTH - PANEL_MARGIN)),
y: Math.min(Math.max(PANEL_MARGIN, y), Math.max(PANEL_MARGIN, viewportHeight - panelHeight - PANEL_MARGIN)),
};
},
[PANEL_MARGIN, PANEL_WIDTH],
);
const btnBase: React.CSSProperties = {
padding: '6px 10px',
borderRadius: 6,
border: '1px solid rgba(148,163,184,0.45)',
background: 'rgba(30,41,59,0.8)',
color: '#e2e8f0',
cursor: 'pointer',
};
const [position, setPosition] = useState(() => {
if (typeof window === 'undefined') {
return { x: PANEL_MARGIN, y: PANEL_DEFAULT_TOP };
}
return {
x: Math.max(PANEL_MARGIN, window.innerWidth - PANEL_WIDTH - PANEL_RIGHT_RESERVED),
y: PANEL_DEFAULT_TOP,
};
});
interface GlobalTrackReplayPanelProps {
isVesselListOpen?: boolean;
onToggleVesselList?: () => void;
}
export function GlobalTrackReplayPanel({ isVesselListOpen, onToggleVesselList }: GlobalTrackReplayPanelProps) {
const tracks = useTrackQueryStore((state) => state.tracks);
const isLoading = useTrackQueryStore((state) => state.isLoading);
const error = useTrackQueryStore((state) => state.error);
const queryContext = useTrackQueryStore((state) => state.queryContext);
const multiQueryContext = useTrackQueryStore((state) => state.multiQueryContext);
const disabledVesselIds = useTrackQueryStore((state) => state.disabledVesselIds);
const showPoints = useTrackQueryStore((state) => state.showPoints);
const showVirtualShip = useTrackQueryStore((state) => state.showVirtualShip);
const showLabels = useTrackQueryStore((state) => state.showLabels);
const showTrail = useTrackQueryStore((state) => state.showTrail);
const hideLiveShips = useTrackQueryStore((state) => state.hideLiveShips);
const setShowPoints = useTrackQueryStore((state) => state.setShowPoints);
const setShowVirtualShip = useTrackQueryStore((state) => state.setShowVirtualShip);
const setShowLabels = useTrackQueryStore((state) => state.setShowLabels);
const setShowTrail = useTrackQueryStore((state) => state.setShowTrail);
const setHideLiveShips = useTrackQueryStore((state) => state.setHideLiveShips);
const closeTrackQuery = useTrackQueryStore((state) => state.closeQuery);
const requery = useTrackQueryStore((state) => state.requery);
const toggleVesselEnabled = useTrackQueryStore((state) => state.toggleVesselEnabled);
const isPlaying = useTrackPlaybackStore((state) => state.isPlaying);
const currentTime = useTrackPlaybackStore((state) => state.currentTime);
const startTime = useTrackPlaybackStore((state) => state.startTime);
const endTime = useTrackPlaybackStore((state) => state.endTime);
const playbackSpeed = useTrackPlaybackStore((state) => state.playbackSpeed);
const loop = useTrackPlaybackStore((state) => state.loop);
const play = useTrackPlaybackStore((state) => state.play);
const pause = useTrackPlaybackStore((state) => state.pause);
const stop = useTrackPlaybackStore((state) => state.stop);
const setCurrentTime = useTrackPlaybackStore((state) => state.setCurrentTime);
const setPlaybackSpeed = useTrackPlaybackStore((state) => state.setPlaybackSpeed);
const timeSyncKey = `${startTime}:${endTime}`;
const [editState, setEditState] = useState({ start: '', end: '', syncKey: '' });
if (editState.syncKey !== timeSyncKey && startTime > 0 && endTime > 0) {
setEditState({
start: toDateTimeLocalKST(startTime),
end: toDateTimeLocalKST(endTime),
syncKey: timeSyncKey,
});
}
const editStartTime = editState.start;
const editEndTime = editState.end;
const setEditStartTime = (v: string) => setEditState((prev) => ({ ...prev, start: v }));
const setEditEndTime = (v: string) => setEditState((prev) => ({ ...prev, end: v }));
const [requeryWarning, setRequeryWarning] = useState<string | null>(null);
const handleRequery = useCallback(() => {
if (!editStartTime || !editEndTime) return;
const sMs = new Date(fromDateTimeLocalKST(editStartTime)).getTime();
const eMs = new Date(fromDateTimeLocalKST(editEndTime)).getTime();
const maxMs = MAX_QUERY_DAYS * 86_400_000;
if (eMs - sMs > maxMs) {
const clamped = toDateTimeLocalKST(sMs + maxMs);
setEditEndTime(clamped);
setRequeryWarning(`최대 ${MAX_QUERY_DAYS}일 초과 — 종료일을 자동 조정했습니다`);
return;
}
setRequeryWarning(null);
requery(fromDateTimeLocalKST(editStartTime), fromDateTimeLocalKST(editEndTime));
}, [editStartTime, editEndTime, requery]);
const handleExportCsv = useCallback(() => {
if (tracks.length === 0) return;
exportTrackCsv(tracks, queryContext, multiQueryContext);
}, [tracks, queryContext, multiQueryContext]);
const toggleLoop = useTrackPlaybackStore((state) => state.toggleLoop);
const progress = useMemo(() => {
if (endTime <= startTime) return 0;
return ((currentTime - startTime) / (endTime - startTime)) * 100;
}, [startTime, endTime, currentTime]);
const isVisible = isLoading || tracks.length > 0 || !!error;
const isMultiMode = multiQueryContext != null;
const vesselCount = tracks.length;
const [isVesselListExpanded, setIsVesselListExpanded] = useState(false);
const hasRequeryContext = isMultiMode || !!queryContext;
useEffect(() => {
if (!isVisible) return;
if (typeof window === 'undefined') return;
const onResize = () => {
setPosition((prev) => clampPosition(prev.x, prev.y));
};
window.addEventListener('resize', onResize);
return () => window.removeEventListener('resize', onResize);
}, [clampPosition, isVisible]);
useEffect(() => {
if (!isVisible) return;
const onPointerMove = (event: PointerEvent) => {
const drag = dragRef.current;
if (!drag || drag.pointerId !== event.pointerId) return;
setPosition(() => {
const nextX = drag.originX + (event.clientX - drag.startX);
const nextY = drag.originY + (event.clientY - drag.startY);
return clampPosition(nextX, nextY);
});
};
const stopDrag = (event: PointerEvent) => {
const drag = dragRef.current;
if (!drag || drag.pointerId !== event.pointerId) return;
dragRef.current = null;
setIsDragging(false);
};
window.addEventListener('pointermove', onPointerMove);
window.addEventListener('pointerup', stopDrag);
window.addEventListener('pointercancel', stopDrag);
return () => {
window.removeEventListener('pointermove', onPointerMove);
window.removeEventListener('pointerup', stopDrag);
window.removeEventListener('pointercancel', stopDrag);
};
}, [clampPosition, isVisible]);
const handleHeaderPointerDown = useCallback(
(event: ReactPointerEvent<HTMLDivElement>) => {
if (event.button !== 0) return;
dragRef.current = {
pointerId: event.pointerId,
startX: event.clientX,
startY: event.clientY,
originX: position.x,
originY: position.y,
};
setIsDragging(true);
try {
event.currentTarget.setPointerCapture(event.pointerId);
} catch {
// ignore
}
},
[position.x, position.y],
);
if (!isVisible) return null;
return (
<div
ref={panelRef}
style={{
position: 'absolute',
bottom: 12,
left: '50%',
transform: 'translateX(-50%)',
width: 'min(95vw, 700px)',
left: position.x,
top: position.y,
width: PANEL_WIDTH,
background: 'rgba(15,23,42,0.94)',
border: '1px solid rgba(148,163,184,0.35)',
borderRadius: 12,
@ -146,165 +161,147 @@ export function GlobalTrackReplayPanel({ isVesselListOpen, onToggleVesselList }:
boxShadow: '0 8px 24px rgba(2,6,23,0.45)',
}}
>
{/* Header */}
<div style={{ display: 'flex', alignItems: 'center', justifyContent: 'space-between', marginBottom: 8 }}>
<strong style={{ fontSize: 13 }}>
Track Replay{vesselCount > 0 ? ` (${vesselCount}척)` : ''}
</strong>
<div style={{ display: 'flex', gap: 6 }}>
{onToggleVesselList && (
<button
type="button"
onClick={onToggleVesselList}
style={{
fontSize: 11,
padding: '4px 8px',
borderRadius: 6,
border: `1px solid ${isVesselListOpen ? 'rgba(96,165,250,0.7)' : 'rgba(96,165,250,0.35)'}`,
background: isVesselListOpen ? 'rgba(37,99,235,0.35)' : 'rgba(37,99,235,0.12)',
color: isVesselListOpen ? '#bfdbfe' : '#93c5fd',
cursor: 'pointer',
}}
>
</button>
)}
<button
type="button"
onClick={() => closeTrackQuery()}
style={{
fontSize: 11,
padding: '4px 8px',
borderRadius: 6,
border: '1px solid rgba(148,163,184,0.5)',
background: 'rgba(30,41,59,0.7)',
color: '#e2e8f0',
cursor: 'pointer',
}}
>
</button>
</div>
<div
onPointerDown={handleHeaderPointerDown}
style={{
display: 'flex',
alignItems: 'center',
justifyContent: 'space-between',
marginBottom: 8,
cursor: isDragging ? 'grabbing' : 'grab',
userSelect: 'none',
touchAction: 'none',
}}
>
<strong style={{ fontSize: 13 }}>Track Replay</strong>
<button
type="button"
onClick={() => closeTrackQuery()}
onPointerDown={(event) => event.stopPropagation()}
style={{
fontSize: 11,
padding: '4px 8px',
borderRadius: 6,
border: '1px solid rgba(148,163,184,0.5)',
background: 'rgba(30,41,59,0.7)',
color: '#e2e8f0',
cursor: 'pointer',
}}
>
</button>
</div>
{error ? <div style={{ marginBottom: 8, color: '#fca5a5', fontSize: 12 }}>{error}</div> : null}
{requeryWarning ? <div style={{ marginBottom: 8, color: '#fbbf24', fontSize: 12 }}>{requeryWarning}</div> : null}
{isLoading ? <div style={{ marginBottom: 8, fontSize: 12 }}> ...</div> : null}
{/* Vessel list — dynamic layout */}
{isMultiMode && vesselCount > 0 ? (
<VesselListSection
tracks={tracks}
disabledVesselIds={disabledVesselIds}
toggleVesselEnabled={toggleVesselEnabled}
vesselCount={vesselCount}
isExpanded={isVesselListExpanded}
onToggleExpand={() => setIsVesselListExpanded((v) => !v)}
/>
) : vesselCount > 0 ? (
<div style={{ fontSize: 11, color: '#93c5fd', marginBottom: 6 }}> {vesselCount}</div>
{error ? (
<div style={{ marginBottom: 8, color: '#fca5a5', fontSize: 12 }}>{error}</div>
) : null}
{/* Date range editing */}
<div style={{ display: 'flex', flexDirection: 'column', gap: 4, marginBottom: 8 }}>
<div style={{ display: 'flex', alignItems: 'center', gap: 6 }}>
<label style={{ fontSize: 11, minWidth: 28, color: '#94a3b8' }}></label>
<input type="datetime-local" title="시작 시각" value={editStartTime} onChange={(e) => setEditStartTime(e.target.value)} disabled={isLoading || !hasRequeryContext} style={inputStyle} />
</div>
<div style={{ display: 'flex', alignItems: 'center', gap: 6 }}>
<label style={{ fontSize: 11, minWidth: 28, color: '#94a3b8' }}></label>
<input type="datetime-local" title="종료 시각" value={editEndTime} onChange={(e) => setEditEndTime(e.target.value)} disabled={isLoading || !hasRequeryContext} style={inputStyle} />
</div>
<div style={{ display: 'flex', gap: 6 }}>
<button type="button" onClick={handleRequery} disabled={isLoading || !hasRequeryContext || !editStartTime || !editEndTime} style={{ flex: 1, padding: '5px 8px', fontSize: 11, borderRadius: 6, border: '1px solid rgba(96,165,250,0.5)', background: 'rgba(37,99,235,0.25)', color: '#93c5fd', cursor: 'pointer' }}>
</button>
<button type="button" onClick={handleExportCsv} disabled={isLoading || tracks.length === 0} style={{ flex: 1, padding: '5px 8px', fontSize: 11, borderRadius: 6, border: '1px solid rgba(74,222,128,0.5)', background: 'rgba(22,163,74,0.2)', color: '#86efac', cursor: 'pointer' }}>
CSV
</button>
</div>
{isLoading ? <div style={{ marginBottom: 8, fontSize: 12 }}> ...</div> : null}
<div style={{ fontSize: 11, color: '#93c5fd', marginBottom: 8 }}>
{tracks.length} · {formatDateTime(startTime)} ~ {formatDateTime(endTime)}
</div>
{/* Playback controls */}
<div style={{ display: 'flex', gap: 8, marginBottom: 10 }}>
<button type="button" onClick={() => (isPlaying ? pause() : play())} disabled={tracks.length === 0} style={btnBase}>
<button
type="button"
onClick={() => (isPlaying ? pause() : play())}
disabled={tracks.length === 0}
style={{
padding: '6px 10px',
borderRadius: 6,
border: '1px solid rgba(148,163,184,0.45)',
background: 'rgba(30,41,59,0.8)',
color: '#e2e8f0',
cursor: 'pointer',
}}
>
{isPlaying ? '일시정지' : '재생'}
</button>
<button type="button" onClick={() => stop()} disabled={tracks.length === 0} style={btnBase}>
<button
type="button"
onClick={() => stop()}
disabled={tracks.length === 0}
style={{
padding: '6px 10px',
borderRadius: 6,
border: '1px solid rgba(148,163,184,0.45)',
background: 'rgba(30,41,59,0.8)',
color: '#e2e8f0',
cursor: 'pointer',
}}
>
</button>
<label style={{ fontSize: 12, display: 'flex', alignItems: 'center', gap: 4 }}>
<select value={playbackSpeed} onChange={(event) => setPlaybackSpeed(Number(event.target.value))} style={{ background: 'rgba(30,41,59,0.85)', border: '1px solid rgba(148,163,184,0.45)', borderRadius: 6, color: '#e2e8f0', fontSize: 12, padding: '4px 6px' }}>
<select
value={playbackSpeed}
onChange={(event) => setPlaybackSpeed(Number(event.target.value))}
style={{
background: 'rgba(30,41,59,0.85)',
border: '1px solid rgba(148,163,184,0.45)',
borderRadius: 6,
color: '#e2e8f0',
fontSize: 12,
padding: '4px 6px',
}}
>
{TRACK_PLAYBACK_SPEED_OPTIONS.map((speed) => (
<option key={speed} value={speed}>{speed}x</option>
<option key={speed} value={speed}>
{speed}x
</option>
))}
</select>
</label>
</div>
{/* Timeline slider */}
<div style={{ marginBottom: 10 }}>
<input type="range" title="타임라인" min={startTime} max={endTime || startTime + 1} value={currentTime} onChange={(event) => setCurrentTime(Number(event.target.value))} style={{ width: '100%' }} disabled={tracks.length === 0 || endTime <= startTime} />
<input
type="range"
min={startTime}
max={endTime || startTime + 1}
value={currentTime}
onChange={(event) => setCurrentTime(Number(event.target.value))}
style={{ width: '100%' }}
disabled={tracks.length === 0 || endTime <= startTime}
/>
<div style={{ display: 'flex', justifyContent: 'space-between', fontSize: 11, color: '#94a3b8' }}>
<span>{formatDateTime(currentTime)}</span>
<span>{Math.max(0, Math.min(100, progress)).toFixed(1)}%</span>
</div>
</div>
{/* Display toggles */}
<div style={{ display: 'grid', gridTemplateColumns: 'repeat(2, minmax(0, 1fr))', gap: 6, fontSize: 12 }}>
<label><input type="checkbox" checked={showPoints} onChange={(event) => setShowPoints(event.target.checked)} /> </label>
<label><input type="checkbox" checked={showLabels} onChange={(event) => setShowLabels(event.target.checked)} /> </label>
<label><input type="checkbox" checked={showTrail} onChange={(event) => setShowTrail(event.target.checked)} /> </label>
<label><input type="checkbox" checked={hideLiveShips} onChange={(event) => setHideLiveShips(event.target.checked)} /> </label>
<div style={{ display: 'grid', gridTemplateColumns: 'repeat(3, minmax(0, 1fr))', gap: 6, fontSize: 12 }}>
<label>
<input type="checkbox" checked={showPoints} onChange={(event) => setShowPoints(event.target.checked)} />
</label>
<label>
<input
type="checkbox"
checked={showVirtualShip}
onChange={(event) => setShowVirtualShip(event.target.checked)}
/>{' '}
</label>
<label>
<input type="checkbox" checked={showLabels} onChange={(event) => setShowLabels(event.target.checked)} />
</label>
<label>
<input type="checkbox" checked={showTrail} onChange={(event) => setShowTrail(event.target.checked)} />
</label>
<label>
<input
type="checkbox"
checked={hideLiveShips}
onChange={(event) => setHideLiveShips(event.target.checked)}
/>{' '}
</label>
<label>
<input type="checkbox" checked={loop} onChange={() => toggleLoop()} />
</label>
</div>
</div>
);
}
/* ── Vessel list sub-component ── */
interface VesselListSectionProps {
tracks: ProcessedTrack[];
disabledVesselIds: Set<string>;
toggleVesselEnabled: (vesselId: string) => void;
vesselCount: number;
isExpanded: boolean;
onToggleExpand: () => void;
}
function VesselListSection({ tracks, disabledVesselIds, toggleVesselEnabled, vesselCount, isExpanded, onToggleExpand }: VesselListSectionProps) {
const showExpandToggle = vesselCount >= 5;
const alwaysShow = vesselCount <= 4;
const isListVisible = alwaysShow || isExpanded;
return (
<div style={{ marginBottom: 8 }}>
<div style={{ display: 'flex', alignItems: 'center', gap: 6, marginBottom: 4 }}>
<span style={{ fontSize: 11, color: '#93c5fd' }}> {vesselCount}</span>
{showExpandToggle && (
<button type="button" onClick={onToggleExpand} style={{ fontSize: 10, color: '#94a3b8', background: 'none', border: 'none', cursor: 'pointer', padding: '2px 4px' }}>
{isExpanded ? '▴ 접기' : '▾ 펼치기'}
</button>
)}
</div>
{isListVisible && (
<div style={{ display: 'flex', flexWrap: 'wrap', gap: 4, maxHeight: showExpandToggle ? 120 : undefined, overflowY: showExpandToggle ? 'auto' : undefined }}>
{tracks.map((track) => {
const isEnabled = !disabledVesselIds.has(track.vesselId);
const kindColor = SHIP_KIND_COLORS[track.shipKindCode] || '#607D8B';
return (
<label key={track.vesselId} style={{ display: 'inline-flex', alignItems: 'center', gap: 3, padding: '2px 6px', borderRadius: 999, background: isEnabled ? 'rgba(148,163,184,0.12)' : 'rgba(148,163,184,0.04)', fontSize: 10, color: isEnabled ? '#cbd5e1' : '#64748B', cursor: 'pointer', opacity: isEnabled ? 1 : 0.5 }}>
<input type="checkbox" checked={isEnabled} onChange={() => toggleVesselEnabled(track.vesselId)} style={{ width: 10, height: 10, accentColor: kindColor }} />
<span style={{ width: 6, height: 6, borderRadius: '50%', background: kindColor, flexShrink: 0 }} />
{track.shipName}
<span style={{ color: '#64748B' }}>({track.targetId.slice(-5)})</span>
</label>
);
})}
</div>
)}
</div>
);
}

파일 보기

@ -1,214 +0,0 @@
import { useState, useEffect, useCallback, type CSSProperties } from 'react';
import type { DerivedLegacyVessel } from '../../features/legacyDashboard/model/types';
import { VESSEL_TYPES } from '../../entities/vessel/model/meta';
import type { SortKey, SortDir } from './VesselSelectModal';
interface VesselSelectGridProps {
vessels: DerivedLegacyVessel[];
selectedMmsis: Set<number>;
toggleMmsi: (mmsi: number) => void;
setMmsis: (mmsis: Set<number>) => void;
sortKey: SortKey | null;
sortDir: SortDir;
onSort: (key: SortKey) => void;
}
interface DragState {
startIdx: number;
endIdx: number;
direction: 'check' | 'uncheck';
}
const STYLE_TABLE: CSSProperties = {
width: '100%',
borderCollapse: 'collapse',
fontSize: 11,
};
const STYLE_TH: CSSProperties = {
position: 'sticky',
top: 0,
background: 'rgba(15,23,42,0.98)',
color: '#94a3b8',
textAlign: 'left',
padding: '6px 8px',
borderBottom: '1px solid rgba(148,163,184,0.2)',
fontWeight: 500,
cursor: 'pointer',
userSelect: 'none',
};
function getSortIndicator(col: SortKey, sortKey: SortKey | null, sortDir: SortDir): string {
if (sortKey !== col) return ' ';
return sortDir === 'asc' ? ' ▲' : ' ▼';
}
const STYLE_TH_CHECKBOX: CSSProperties = {
...STYLE_TH,
width: 28,
};
function getTdStyle(isSelected: boolean): CSSProperties {
return {
padding: '5px 8px',
borderBottom: '1px solid rgba(148,163,184,0.08)',
cursor: 'pointer',
background: isSelected ? 'rgba(59,130,246,0.12)' : undefined,
};
}
function getStateBadgeStyle(isFishing: boolean, isTransit: boolean): CSSProperties {
const color = isFishing ? '#22C55E' : isTransit ? '#3B82F6' : '#64748B';
return {
background: `${color}22`,
color,
borderRadius: 3,
padding: '1px 4px',
fontSize: 10,
};
}
function getDotStyle(color: string): CSSProperties {
return {
display: 'inline-block',
width: 8,
height: 8,
borderRadius: '50%',
background: color,
marginRight: 4,
verticalAlign: 'middle',
};
}
function isInDragRange(idx: number, drag: DragState): boolean {
const min = Math.min(drag.startIdx, drag.endIdx);
const max = Math.max(drag.startIdx, drag.endIdx);
return idx >= min && idx <= max;
}
function getDragHighlight(direction: 'check' | 'uncheck'): string {
return direction === 'check' ? 'rgba(59,130,246,0.18)' : 'rgba(148,163,184,0.12)';
}
export function VesselSelectGrid({ vessels, selectedMmsis, toggleMmsi, setMmsis, sortKey, sortDir, onSort }: VesselSelectGridProps) {
const [dragState, setDragState] = useState<DragState | null>(null);
const handleMouseDown = useCallback(
(idx: number, e: React.MouseEvent) => {
// 체크박스 직접 클릭은 무시 (기존 onChange 처리)
if ((e.target as HTMLElement).tagName === 'INPUT') return;
e.preventDefault();
const isSelected = selectedMmsis.has(vessels[idx].mmsi);
setDragState({ startIdx: idx, endIdx: idx, direction: isSelected ? 'uncheck' : 'check' });
},
[vessels, selectedMmsis],
);
const handleMouseEnter = useCallback(
(idx: number) => {
if (!dragState) return;
setDragState((prev) => (prev ? { ...prev, endIdx: idx } : null));
},
[dragState],
);
// document-level mouseup: 드래그 종료
useEffect(() => {
if (!dragState) return;
const handleMouseUp = () => {
const { startIdx, endIdx, direction } = dragState;
if (startIdx === endIdx) {
// 단일 클릭
toggleMmsi(vessels[startIdx].mmsi);
} else {
// 범위 선택
const min = Math.min(startIdx, endIdx);
const max = Math.max(startIdx, endIdx);
const newSet = new Set(selectedMmsis);
for (let i = min; i <= max; i++) {
const mmsi = vessels[i].mmsi;
if (direction === 'check') newSet.add(mmsi);
else newSet.delete(mmsi);
}
setMmsis(newSet);
}
setDragState(null);
};
document.addEventListener('mouseup', handleMouseUp);
return () => document.removeEventListener('mouseup', handleMouseUp);
}, [dragState, vessels, selectedMmsis, toggleMmsi, setMmsis]);
return (
<table style={{ ...STYLE_TABLE, userSelect: dragState ? 'none' : undefined }}>
<thead>
<tr>
<th style={STYLE_TH_CHECKBOX} />
<th style={STYLE_TH} onClick={() => onSort('shipCode')}>{getSortIndicator('shipCode', sortKey, sortDir)}</th>
<th style={STYLE_TH} onClick={() => onSort('permitNo')}>{getSortIndicator('permitNo', sortKey, sortDir)}</th>
<th style={STYLE_TH} onClick={() => onSort('name')}>{getSortIndicator('name', sortKey, sortDir)}</th>
<th style={STYLE_TH} onClick={() => onSort('mmsi')}>MMSI{getSortIndicator('mmsi', sortKey, sortDir)}</th>
<th style={STYLE_TH} onClick={() => onSort('sog')}>{getSortIndicator('sog', sortKey, sortDir)}</th>
<th style={STYLE_TH} onClick={() => onSort('state')}>{getSortIndicator('state', sortKey, sortDir)}</th>
</tr>
</thead>
<tbody>
{vessels.map((v, idx) => {
const isSelected = selectedMmsis.has(v.mmsi);
const meta = VESSEL_TYPES[v.shipCode];
const inRange = dragState ? isInDragRange(idx, dragState) : false;
// 드래그 중 범위 내 행 → 예상 상태 미리보기
let rowBg: string | undefined;
if (inRange && dragState) {
rowBg = getDragHighlight(dragState.direction);
} else if (isSelected) {
rowBg = 'rgba(59,130,246,0.12)';
}
const tdStyle = getTdStyle(false); // 배경은 tr에서 관리
const stateBadgeStyle = getStateBadgeStyle(v.state.isFishing, v.state.isTransit);
const mmsiDisplay = String(v.mmsi);
const sogDisplay = v.sog !== null ? `${v.sog.toFixed(1)} kt` : '';
// 드래그 중 범위 내 체크 상태 미리보기
const previewChecked = inRange && dragState
? dragState.direction === 'check'
: isSelected;
return (
<tr
key={v.mmsi}
style={{ cursor: 'pointer', background: rowBg }}
onMouseDown={(e) => handleMouseDown(idx, e)}
onMouseEnter={() => handleMouseEnter(idx)}
>
<td style={tdStyle}>
<input
type="checkbox"
title="선택"
checked={previewChecked}
onChange={() => toggleMmsi(v.mmsi)}
onClick={(e) => e.stopPropagation()}
style={{ cursor: 'pointer' }}
/>
</td>
<td style={tdStyle}>
<span style={getDotStyle(meta.color)} />
{v.shipCode}
</td>
<td style={tdStyle}>{v.permitNo}</td>
<td style={tdStyle}>{v.name}</td>
<td style={tdStyle}>{mmsiDisplay}</td>
<td style={tdStyle}>{sogDisplay}</td>
<td style={tdStyle}>
<span style={stateBadgeStyle}>{v.state.label}</span>
</td>
</tr>
);
})}
</tbody>
</table>
);
}

파일 보기

@ -1,577 +0,0 @@
import { useMemo, useEffect, useCallback, useState, useRef, type CSSProperties } from 'react';
import type { VesselSelectModalState } from '../../features/vesselSelect/hooks/useVesselSelectModal';
import type { DerivedLegacyVessel } from '../../features/legacyDashboard/model/types';
import { VESSEL_TYPE_ORDER, VESSEL_TYPES } from '../../entities/vessel/model/meta';
import { MAX_VESSEL_GROUPS } from '../../features/vesselSelect/model/types';
import { VesselSelectGrid } from './VesselSelectGrid';
import { ToggleButton, TextInput, Button } from '@wing/ui';
export type SortKey = 'shipCode' | 'permitNo' | 'name' | 'mmsi' | 'sog' | 'state';
export type SortDir = 'asc' | 'desc';
interface VesselSelectModalProps {
modal: VesselSelectModalState;
vessels: DerivedLegacyVessel[];
}
const STRIP_RE = /[\s\-.,_]/g;
function normalize(s: string): string {
return s.replace(STRIP_RE, '').toLowerCase();
}
function matchesQuery(v: DerivedLegacyVessel, nq: string): boolean {
if (normalize(v.permitNo).includes(nq)) return true;
if (normalize(v.name).includes(nq)) return true;
if (v.legacy.shipNameRoman && normalize(v.legacy.shipNameRoman).includes(nq)) return true;
if (v.legacy.shipNameCn && normalize(v.legacy.shipNameCn).includes(nq)) return true;
if (normalize(String(v.mmsi)).includes(nq)) return true;
return false;
}
const STATE_LABELS = ['조업', '항해', '정지', '저속', '미상'] as const;
const STATE_COLORS: Record<string, string> = {
: '#22C55E',
: '#3B82F6',
: '#64748B',
: '#EAB308',
: '#6B7280',
};
const STYLE_OVERLAY: CSSProperties = {
position: 'fixed',
inset: 0,
zIndex: 1050,
display: 'flex',
alignItems: 'center',
justifyContent: 'center',
pointerEvents: 'none',
};
const STYLE_BACKDROP: CSSProperties = {
position: 'fixed',
inset: 0,
background: 'rgba(0,0,0,0.35)',
pointerEvents: 'auto',
};
const STYLE_CONTENT: CSSProperties = {
position: 'relative',
display: 'flex',
flexDirection: 'column',
maxWidth: 720,
width: '95vw',
maxHeight: '57vh',
background: 'rgba(15,23,42,0.96)',
backdropFilter: 'blur(12px)',
border: '1px solid rgba(148,163,184,0.25)',
borderRadius: 12,
color: '#e2e8f0',
overflow: 'hidden',
pointerEvents: 'auto',
};
const STYLE_HEADER: CSSProperties = {
display: 'flex',
alignItems: 'center',
justifyContent: 'space-between',
padding: '10px 16px',
borderBottom: '1px solid rgba(148,163,184,0.15)',
flexShrink: 0,
cursor: 'grab',
userSelect: 'none',
};
const STYLE_CLOSE_BTN: CSSProperties = {
background: 'transparent',
border: 'none',
color: '#94a3b8',
cursor: 'pointer',
fontSize: 16,
lineHeight: 1,
padding: '2px 6px',
};
const STYLE_FILTERS: CSSProperties = {
display: 'flex',
flexDirection: 'column',
gap: 6,
padding: '8px 16px',
borderBottom: '1px solid rgba(148,163,184,0.1)',
flexShrink: 0,
};
const STYLE_FILTER_ROW: CSSProperties = {
display: 'flex',
alignItems: 'center',
flexWrap: 'wrap',
gap: 5,
};
const STYLE_FILTER_LABEL: CSSProperties = {
fontSize: 11,
color: '#64748b',
minWidth: 28,
flexShrink: 0,
};
const STYLE_DOT = (color: string): CSSProperties => ({
display: 'inline-block',
width: 7,
height: 7,
borderRadius: '50%',
background: color,
marginRight: 3,
verticalAlign: 'middle',
});
const STYLE_GROUP_BAR: CSSProperties = {
display: 'flex',
alignItems: 'center',
gap: 6,
padding: '6px 16px',
borderBottom: '1px solid rgba(148,163,184,0.1)',
flexShrink: 0,
overflowX: 'auto',
};
const STYLE_GROUP_BTN: CSSProperties = {
display: 'inline-flex',
alignItems: 'center',
gap: 4,
padding: '3px 8px',
fontSize: 11,
borderRadius: 4,
border: '1px solid rgba(148,163,184,0.3)',
background: 'rgba(30,41,59,0.6)',
color: '#cbd5e1',
cursor: 'pointer',
whiteSpace: 'nowrap',
flexShrink: 0,
};
const STYLE_GROUP_DELETE: CSSProperties = {
fontSize: 9,
color: '#94a3b8',
cursor: 'pointer',
padding: '0 2px',
lineHeight: 1,
};
const STYLE_GROUP_INPUT: CSSProperties = {
fontSize: 11,
padding: '3px 6px',
borderRadius: 4,
border: '1px solid rgba(59,130,246,0.5)',
background: 'rgba(30,41,59,0.8)',
color: '#e2e8f0',
outline: 'none',
width: 100,
};
const STYLE_SEARCH: CSSProperties = {
padding: '6px 16px',
borderBottom: '1px solid rgba(148,163,184,0.1)',
flexShrink: 0,
};
const STYLE_GRID: CSSProperties = {
flex: 1,
overflowY: 'auto',
minHeight: 0,
};
const STYLE_DATE_BAR: CSSProperties = {
display: 'flex',
alignItems: 'center',
flexWrap: 'wrap',
gap: 6,
padding: '8px 16px',
borderTop: '1px solid rgba(148,163,184,0.1)',
flexShrink: 0,
fontSize: 11,
};
const STYLE_DATETIME_INPUT: CSSProperties = {
fontSize: 11,
padding: '3px 6px',
borderRadius: 4,
border: '1px solid rgba(148,163,184,0.35)',
background: 'rgba(30,41,59,0.8)',
color: '#e2e8f0',
colorScheme: 'dark',
};
const STYLE_FOOTER: CSSProperties = {
display: 'flex',
alignItems: 'center',
gap: 10,
padding: '8px 16px',
borderTop: '1px solid rgba(148,163,184,0.15)',
flexShrink: 0,
};
const STYLE_FOOTER_SPACER: CSSProperties = { flex: 1 };
const STYLE_SEPARATOR: CSSProperties = {
width: 1,
height: 14,
background: 'rgba(148,163,184,0.2)',
flexShrink: 0,
};
export function VesselSelectModal({ modal, vessels }: VesselSelectModalProps) {
const {
isOpen,
close,
selectedMmsis,
toggleMmsi,
setMmsis,
selectAllFiltered,
clearAll,
searchQuery,
setSearchQuery,
shipCodeFilter,
toggleShipCode,
toggleAllShipCodes,
onlySailing,
setOnlySailing,
stateFilter,
toggleStateFilter,
toggleAllStates,
startTime,
endTime,
setStartTime,
setEndTime,
applyPresetDays,
isQuerying,
submitQuery,
position,
setPosition,
selectionWarning,
groups,
saveGroup,
deleteGroup,
applyGroup,
} = modal;
// ── 그룹 저장 입력 ──
const [isSavingGroup, setIsSavingGroup] = useState(false);
const [groupNameDraft, setGroupNameDraft] = useState('');
const [groupWarning, setGroupWarning] = useState<string | null>(null);
const groupInputRef = useRef<HTMLInputElement>(null);
const handleSaveGroup = useCallback(() => {
if (!groupNameDraft.trim()) {
setIsSavingGroup(false);
return;
}
const warn = saveGroup(groupNameDraft, [...selectedMmsis]);
if (warn) {
setGroupWarning(warn);
} else {
setGroupWarning(null);
}
setIsSavingGroup(false);
setGroupNameDraft('');
}, [groupNameDraft, saveGroup, selectedMmsis]);
// ── 정렬 ──
const [sortKey, setSortKey] = useState<SortKey | null>(null);
const [sortDir, setSortDir] = useState<SortDir>('asc');
const handleSort = useCallback((key: SortKey) => {
setSortKey((prev) => {
if (prev === key) {
setSortDir((d) => (d === 'asc' ? 'desc' : 'asc'));
return key;
}
setSortDir('asc');
return key;
});
}, []);
// ── 필터 ──
const filteredVessels = useMemo(() => {
let list = vessels;
if (shipCodeFilter.size > 0) {
list = list.filter((v) => shipCodeFilter.has(v.shipCode));
}
if (stateFilter.size > 0) {
list = list.filter((v) => stateFilter.has(v.state.label));
}
if (onlySailing) {
list = list.filter((v) => v.state.isFishing || v.state.isTransit);
}
const nq = searchQuery.length >= 2 ? normalize(searchQuery) : '';
if (nq) {
list = list.filter((v) => matchesQuery(v, nq));
}
return list;
}, [vessels, shipCodeFilter, stateFilter, onlySailing, searchQuery]);
const sortedVessels = useMemo(() => {
if (!sortKey) return filteredVessels;
const arr = [...filteredVessels];
arr.sort((a, b) => {
let cmp = 0;
switch (sortKey) {
case 'shipCode': cmp = a.shipCode.localeCompare(b.shipCode); break;
case 'permitNo': cmp = a.permitNo.localeCompare(b.permitNo); break;
case 'name': cmp = a.name.localeCompare(b.name, 'ko'); break;
case 'mmsi': cmp = a.mmsi - b.mmsi; break;
case 'sog': cmp = (a.sog ?? -1) - (b.sog ?? -1); break;
case 'state': cmp = a.state.label.localeCompare(b.state.label, 'ko'); break;
}
return sortDir === 'asc' ? cmp : -cmp;
});
return arr;
}, [filteredVessels, sortKey, sortDir]);
// ── Escape 닫기 ──
const handleKeyDown = useCallback(
(e: KeyboardEvent) => {
if (e.key === 'Escape') close();
},
[close],
);
useEffect(() => {
if (!isOpen) return;
document.addEventListener('keydown', handleKeyDown);
return () => document.removeEventListener('keydown', handleKeyDown);
}, [isOpen, handleKeyDown]);
// ── 드래그 ──
const handlePointerDown = useCallback(
(e: React.PointerEvent) => {
if ((e.target as HTMLElement).closest('button')) return;
e.preventDefault();
const startX = e.clientX - position.x;
const startY = e.clientY - position.y;
document.body.style.cursor = 'grabbing';
const handleMove = (ev: PointerEvent) => {
setPosition({ x: ev.clientX - startX, y: ev.clientY - startY });
};
const handleUp = () => {
document.body.style.cursor = '';
document.removeEventListener('pointermove', handleMove);
document.removeEventListener('pointerup', handleUp);
};
document.addEventListener('pointermove', handleMove);
document.addEventListener('pointerup', handleUp);
},
[position, setPosition],
);
// ── 전체 선택 ──
const isAllSelected = filteredVessels.length > 0 && filteredVessels.every((v) => selectedMmsis.has(v.mmsi));
const handleSelectAllChange = useCallback(() => {
if (isAllSelected) clearAll();
else selectAllFiltered(filteredVessels);
}, [isAllSelected, clearAll, selectAllFiltered, filteredVessels]);
const handleContentClick = useCallback((e: React.MouseEvent) => {
e.stopPropagation();
}, []);
if (!isOpen) return null;
return (
<>
<div style={STYLE_BACKDROP} onClick={close} />
<div
style={{
...STYLE_OVERLAY,
}}
>
<div
style={{
...STYLE_CONTENT,
transform: `translate(${position.x}px, ${position.y}px)`,
}}
onClick={handleContentClick}
>
{/* 헤더 (드래그 핸들) */}
<div
style={STYLE_HEADER}
onPointerDown={handlePointerDown}
>
<div style={{ display: 'flex', alignItems: 'center', gap: 6 }}>
<span style={{ color: '#64748b', fontSize: 12 }}></span>
<strong style={{ fontSize: 13 }}> </strong>
<span style={{ color: '#64748b', fontSize: 11 }}>({vessels.length})</span>
</div>
<button style={STYLE_CLOSE_BTN} onClick={close}>
</button>
</div>
{/* 업종 + 상태 필터 */}
<div style={STYLE_FILTERS}>
<div style={STYLE_FILTER_ROW}>
<span style={STYLE_FILTER_LABEL}></span>
<ToggleButton on={shipCodeFilter.size === VESSEL_TYPE_ORDER.length} onClick={() => toggleAllShipCodes(VESSEL_TYPE_ORDER)}>
</ToggleButton>
{VESSEL_TYPE_ORDER.map((code) => {
const meta = VESSEL_TYPES[code];
return (
<ToggleButton key={code} on={shipCodeFilter.has(code)} onClick={() => toggleShipCode(code)}>
<span style={STYLE_DOT(meta.color)} />
{code}
</ToggleButton>
);
})}
</div>
<div style={STYLE_FILTER_ROW}>
<span style={STYLE_FILTER_LABEL}></span>
<ToggleButton on={stateFilter.size === STATE_LABELS.length} onClick={() => toggleAllStates([...STATE_LABELS])}>
</ToggleButton>
{STATE_LABELS.map((label) => (
<ToggleButton key={label} on={stateFilter.has(label)} onClick={() => toggleStateFilter(label)}>
<span style={STYLE_DOT(STATE_COLORS[label])} />
{label}
</ToggleButton>
))}
<label
style={{
display: 'flex',
alignItems: 'center',
gap: 4,
fontSize: 11,
color: '#94a3b8',
cursor: 'pointer',
marginLeft: 4,
}}
>
<input
type="checkbox"
checked={onlySailing}
onChange={(e) => setOnlySailing(e.target.checked)}
style={{ cursor: 'pointer' }}
/>
</label>
</div>
</div>
{/* 그룹 바 */}
{(groups.length > 0 || selectedMmsis.size > 0) && (
<div style={STYLE_GROUP_BAR}>
{isSavingGroup ? (
<input
ref={groupInputRef}
style={STYLE_GROUP_INPUT}
placeholder="그룹명 입력"
value={groupNameDraft}
onChange={(e) => setGroupNameDraft(e.target.value)}
onKeyDown={(e) => {
if (e.key === 'Enter') handleSaveGroup();
if (e.key === 'Escape') { setIsSavingGroup(false); setGroupNameDraft(''); }
}}
onBlur={handleSaveGroup}
autoFocus
/>
) : (
<Button
variant="ghost"
size="sm"
disabled={selectedMmsis.size === 0 || (groups.length >= MAX_VESSEL_GROUPS && !groups.some((g) => g.mmsis.length === selectedMmsis.size))}
title={groups.length >= MAX_VESSEL_GROUPS ? `최대 ${MAX_VESSEL_GROUPS}` : undefined}
onClick={() => { setIsSavingGroup(true); setGroupWarning(null); }}
>
</Button>
)}
{groupWarning && <span style={{ color: '#fca5a5', fontSize: 10 }}>{groupWarning}</span>}
{groups.map((g) => (
<button
type="button"
key={g.id}
style={STYLE_GROUP_BTN}
onClick={() => applyGroup(g)}
title={`${g.mmsis.length}`}
>
{g.name}
<span
style={STYLE_GROUP_DELETE}
onClick={(e) => { e.stopPropagation(); deleteGroup(g.id); }}
title="삭제"
>
</span>
</button>
))}
</div>
)}
{/* 검색 */}
<div style={STYLE_SEARCH}>
<TextInput placeholder="검색: 등록번호 / 선박명 / MMSI" value={searchQuery} onChange={(e) => setSearchQuery(e.target.value)} />
</div>
{/* 그리드 */}
<div style={STYLE_GRID}>
<VesselSelectGrid
vessels={sortedVessels}
selectedMmsis={selectedMmsis}
toggleMmsi={toggleMmsi}
setMmsis={setMmsis}
sortKey={sortKey}
sortDir={sortDir}
onSort={handleSort}
/>
</div>
{/* 기간 설정 바 */}
<div style={STYLE_DATE_BAR}>
{[7, 14, 21, 28].map((d) => (
<Button key={d} variant="ghost" size="sm" onClick={() => applyPresetDays(d)}>
{d}
</Button>
))}
<div style={STYLE_SEPARATOR} />
<label style={{ color: '#94a3b8', display: 'flex', alignItems: 'center', gap: 4 }}>
<input type="datetime-local" value={startTime} onChange={(e) => setStartTime(e.target.value)} style={STYLE_DATETIME_INPUT} />
</label>
<label style={{ color: '#94a3b8', display: 'flex', alignItems: 'center', gap: 4 }}>
<input type="datetime-local" value={endTime} onChange={(e) => setEndTime(e.target.value)} style={STYLE_DATETIME_INPUT} />
</label>
</div>
{/* 푸터 */}
<div style={STYLE_FOOTER}>
<label
style={{
display: 'flex',
alignItems: 'center',
gap: 4,
fontSize: 11,
color: '#cbd5e1',
cursor: 'pointer',
}}
>
<input type="checkbox" checked={isAllSelected} onChange={handleSelectAllChange} style={{ cursor: 'pointer' }} />
({filteredVessels.length})
</label>
<Button variant="ghost" size="sm" disabled={selectedMmsis.size === 0} onClick={clearAll}>
</Button>
{selectionWarning && <span style={{ color: '#fca5a5', fontSize: 11 }}>{selectionWarning}</span>}
<div style={STYLE_FOOTER_SPACER} />
<span style={{ color: '#93c5fd', fontSize: 12 }}> {selectedMmsis.size}</span>
<Button variant="primary" size="md" disabled={selectedMmsis.size === 0} onClick={() => submitQuery(vessels)}>
{isQuerying ? '재조회' : '조회 시작'}
</Button>
</div>
</div>
</div>
</>
);
}

파일 보기

@ -1,45 +0,0 @@
# Release Notes
이 문서는 [Keep a Changelog](https://keepachangelog.com/ko/1.0.0/) 형식을 따릅니다.
## [Unreleased]
## [2026-03-25]
### 추가
- ENC 전자해도 베이스맵 (gcnautical 타일 서버, S-52 49개 레이어 + 73개 스프라이트)
- ENC 설정 패널 — 12개 레이어 토글, 영역 색상 3종, 수심 색상 5단계 커스텀
- 배경색 밝기 기반 선박 라벨 색상 자동 전환
- Globe 선박 아이콘 SDF 테두리 (icon-halo)
### 변경
- Globe 선박 원형 halo/outline 제거 → 아이콘 본체만 표시
- Globe 선박 아이콘 1.3배 스케일, 줌아웃 최소 크기 보장 (minzoom 2)
- 선박명 영문 우선 표시 (영문 → 한자 → AIS 순), 대문자 변환
- 연결선/범위/선단 토글 off 시 인터랙티브 오버레이 완전 차단
- 강조 링/알람 링 클러스터링 연동 (줌아웃 시 미표시 선박 제거)
- 기타 AIS 투명도 상향, Globe 줌아웃 시 가시성 개선
- 폰트 Open Sans 폴백 전면 제거 → Noto Sans 단독
### 기타
- 경고 필터 초기값 false, 연결선/범위/선단 초기 비활성
- 사진 파란 원 아이콘 제거 (Globe + Mercator)
## [2026-03-18]
### 추가
- 다중 항적 선박 그룹 저장/불러오기 (계정별 localStorage, 최대 10개)
- 선박 목록 컬럼별 정렬 (업종/등록번호/선명/MMSI/속력/상태)
- 선박 선택 초기화 버튼
- 모든 선박 우클릭 컨텍스트 메뉴 — 선명 복사, MMSI 복사
### 변경
- 우클릭 항적조회를 대상선박 외 모든 선박으로 컨텍스트 메뉴 확장 (항적조회는 대상선박만 유지)
## [2026-03-10]
### 추가
- OSM 베이스맵 추가 + Base/OSM/Ocean 3-way 라디오 그룹 전환
### 기타
- 팀 워크플로우 v1.6.1 동기화 + 관리 파일 .gitignore 전환

파일 보기

@ -84,7 +84,6 @@ function readPermittedListXlsx(filePath) {
const prev = byPermitNo.get(permitNo);
if (prev) {
if (mmsi && !prev.mmsiList.includes(mmsi)) prev.mmsiList.push(mmsi);
if (!prev.mmsi && mmsi) prev.mmsi = mmsi;
continue;
}
@ -102,7 +101,6 @@ function readPermittedListXlsx(filePath) {
workTerm2: toStr(r.work_term2),
quota: toStr(r.quota),
shipCode: toStr(r.ship_code),
mmsi: mmsi,
mmsiList: mmsi ? [mmsi] : [],
sources: { permittedList: true, checklist: false, fleet906: false },
ownerCn: null,
@ -226,7 +224,6 @@ async function main() {
workTerm2: "",
quota: "",
shipCode: c.shipCode,
mmsi: null,
mmsiList: [],
sources: { permittedList: false, checklist: true, fleet906: false },
ownerCn: c.ownerCn || null,