Compare commits

..

No commits in common. "develop" and "feature/ship-icon-upgrade" have entirely different histories.

66개의 변경된 파일1557개의 추가작업 그리고 2866개의 파일을 삭제

파일 보기

@ -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*)",

파일 보기

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

파일 보기

@ -1,6 +1,6 @@
import { DISPLAY_TZ } from '../../../shared/lib/datetime';
import { haversineNm } from '../../../shared/lib/geo/haversineNm';
import type { MultiTrackQueryContext, ProcessedTrack, TrackQueryContext } from '../model/track.types';
import type { ProcessedTrack, TrackQueryContext } from '../model/track.types';
const BOM = '\uFEFF';
@ -31,14 +31,14 @@ function calcSpeedKnots(track: ProcessedTrack, index: number): number {
}
/** 포인트별 1행: mmsi, longitude, latitude, timestamp, speedKnots */
export function buildDynamicCsv(tracks: ProcessedTrack[], ctx: TrackQueryContext | null, multiCtx?: MultiTrackQueryContext | null): string {
export function buildDynamicCsv(tracks: ProcessedTrack[], ctx: TrackQueryContext | 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);
const trackMmsi = mmsi || track.targetId;
for (let i = 0; i < track.geometry.length; i++) {
rows.push(
[
@ -57,7 +57,7 @@ export function buildDynamicCsv(tracks: ProcessedTrack[], ctx: TrackQueryContext
}
/** 선박별 1행: mmsi, 선명, 업종, 소유주, 선단, 허가번호 등 전체 메타 */
export function buildStaticCsv(tracks: ProcessedTrack[], ctx: TrackQueryContext | null, multiCtx?: MultiTrackQueryContext | null): string {
export function buildStaticCsv(tracks: ProcessedTrack[], ctx: TrackQueryContext | null): string {
const header = [
'mmsi',
'shipName',
@ -83,29 +83,23 @@ export function buildStaticCsv(tracks: ProcessedTrack[], ctx: TrackQueryContext
];
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(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(ctx?.vesselType ?? ''),
escCsv(ctx?.ownerCn),
escCsv(ctx?.ownerRoman),
escCsv(ctx?.permitNo),
escCsv(ctx?.pairPermitNo),
escCsv(ctx?.ton),
escCsv(ctx?.callSign),
escCsv(ctx?.workSeaArea),
escCsv(track.nationalCode),
escCsv(track.stats.totalDistanceNm),
escCsv(track.stats.avgSpeed),
@ -136,12 +130,12 @@ function downloadCsv(csvContent: string, filename: string): void {
URL.revokeObjectURL(url);
}
export function exportTrackCsv(tracks: ProcessedTrack[], ctx: TrackQueryContext | null, multiCtx?: MultiTrackQueryContext | null): void {
export function exportTrackCsv(tracks: ProcessedTrack[], ctx: TrackQueryContext | null): void {
const now = new Date();
const ts = now.toISOString().replace(/[:.]/g, '-').slice(0, 19);
downloadCsv(buildDynamicCsv(tracks, ctx, multiCtx), `track-points-${ts}.csv`);
downloadCsv(buildDynamicCsv(tracks, ctx), `track-points-${ts}.csv`);
setTimeout(() => {
downloadCsv(buildStaticCsv(tracks, ctx, multiCtx), `track-vessel-${ts}.csv`);
downloadCsv(buildStaticCsv(tracks, ctx), `track-vessel-${ts}.csv`);
}, 100);
}

파일 보기

@ -64,25 +64,6 @@ export interface TrackQueryContext {
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;

파일 보기

@ -120,16 +120,16 @@ function convertV2Tracks(rows: V2TrackResponse[]): ProcessedTrack[] {
async function fetchV2Tracks(
startTimeIso: string,
endTimeIso: string,
mmsis: number[],
hasPermitted: boolean,
mmsi: number,
isPermitted: boolean,
): Promise<ProcessedTrack[]> {
const base = (import.meta.env.VITE_TRACK_V2_BASE_URL || '/signal-batch').trim();
const requestBody = {
startTime: startTimeIso,
endTime: endTimeIso,
vessels: mmsis.map(String),
includeChnPrmShip: hasPermitted,
vessels: [String(mmsi)],
includeChnPrmShip: isPermitted,
};
const endpoint = `${base.replace(/\/$/, '')}/api/v2/tracks/vessels`;
@ -156,20 +156,9 @@ 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);
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 fetchV2Tracks(params.startTimeIso, params.endTimeIso, params.mmsi, params.isPermitted ?? false);
}

파일 보기

@ -1,7 +1,7 @@
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, TrackQueryContext } from '../model/track.types';
import { queryTrackByDateRange } from '../services/trackQueryService';
import { useTrackPlaybackStore } from './trackPlaybackStore';
export type TrackQueryStatus = 'idle' | 'loading' | 'ready' | 'error';
@ -16,7 +16,6 @@ interface TrackQueryState {
renderEpoch: number;
lastQueryKey: string | null;
queryContext: TrackQueryContext | null;
multiQueryContext: MultiTrackQueryContext | null;
showPoints: boolean;
showVirtualShip: boolean;
showLabels: boolean;
@ -24,7 +23,6 @@ interface TrackQueryState {
hideLiveShips: boolean;
beginQuery: (queryKey: string, context?: TrackQueryContext) => void;
beginMultiQuery: (queryKey: string, ctx: MultiTrackQueryContext) => Promise<void>;
applyTracksSuccess: (tracks: ProcessedTrack[], queryKey?: string | null) => void;
applyQueryError: (error: string, queryKey?: string | null) => void;
closeQuery: () => void;
@ -55,7 +53,6 @@ export const useTrackQueryStore = create<TrackQueryState>()((set, get) => ({
renderEpoch: 0,
lastQueryKey: null,
queryContext: null,
multiQueryContext: null,
showPoints: true,
showVirtualShip: true,
showLabels: true,
@ -73,48 +70,11 @@ export const useTrackQueryStore = create<TrackQueryState>()((set, get) => ({
queryState: 'loading',
renderEpoch: state.renderEpoch + 1,
lastQueryKey: queryKey,
hideLiveShips: true,
hideLiveShips: false,
queryContext: context ?? state.queryContext,
multiQueryContext: null,
}));
},
beginMultiQuery: async (queryKey: string, ctx: MultiTrackQueryContext) => {
useTrackPlaybackStore.getState().reset();
set((state) => ({
tracks: [],
disabledVesselIds: new Set<string>(),
highlightedVesselId: null,
isLoading: true,
error: null,
queryState: 'loading',
renderEpoch: state.renderEpoch + 1,
lastQueryKey: queryKey,
hideLiveShips: true,
queryContext: null,
multiQueryContext: ctx,
}));
try {
const mmsis = ctx.vessels.map((v) => v.mmsi);
const hasPermitted = ctx.vessels.some((v) => v.isPermitted);
const tracks = await queryMultiTrack({
mmsis,
startTimeIso: ctx.startTimeIso,
endTimeIso: ctx.endTimeIso,
hasPermitted,
});
if (tracks.length > 0) {
get().applyTracksSuccess(tracks, queryKey);
} else {
get().applyQueryError('항적 데이터가 없습니다.', queryKey);
}
} catch (e) {
get().applyQueryError(e instanceof Error ? e.message : '항적 조회에 실패했습니다.', queryKey);
}
},
applyTracksSuccess: (tracks: ProcessedTrack[], queryKey?: string | null) => {
const currentQueryKey = get().lastQueryKey;
if (queryKey != null && queryKey !== currentQueryKey) {
@ -186,21 +146,11 @@ export const useTrackQueryStore = create<TrackQueryState>()((set, get) => ({
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;
@ -285,7 +235,6 @@ export const useTrackQueryStore = create<TrackQueryState>()((set, get) => ({
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";
@ -30,11 +30,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 +67,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 +104,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);
@ -366,7 +351,6 @@ export function DashboardPage() {
onToggleTheme={toggleTheme}
isSidebarOpen={isSidebarOpen}
onMenuToggle={() => setIsSidebarOpen((v) => !v)}
onOpenMultiTrack={vesselSelectModal.open}
/>
<DashboardSidebar
@ -452,20 +436,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 +452,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 +465,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,

파일 보기

@ -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,
@ -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>
);
}

파일 보기

@ -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';
@ -20,8 +20,6 @@ import {
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,
@ -33,12 +31,6 @@ import {
// 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>,
@ -83,7 +75,6 @@ export function useDeckLayers(
ensureMercatorOverlay: () => MapboxOverlay | null;
alarmMmsiMap?: Map<number, LegacyAlarmKind>;
onClickShipPhoto?: (mmsi: number) => void;
shipLabelColors?: import('../lib/labelColor').ShipLabelColors;
},
) {
const {
@ -98,15 +89,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;
@ -125,8 +113,8 @@ export function useDeckLayers(
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);
@ -188,7 +176,6 @@ export function useDeckLayers(
alarmPulseHoverRadius: 12,
shipPhotoTargets,
onClickShipPhoto,
shipLabelColors,
});
const normalizedBaseLayers = sanitizeDeckLayerList(layers);
@ -293,7 +280,6 @@ export function useDeckLayers(
alarmMmsiMap,
shipPhotoTargets,
onClickShipPhoto,
shipLabelColors,
]);
// Mercator 브리딩 애니메이션 (rAF) — 알람 맥동 + 대상 선박 브리딩 합류
@ -322,35 +308,30 @@ export function useDeckLayers(
const now = Date.now();
let updated = mercatorLayersRef.current;
// 1. 알람 맥동 — IconLayer + SVG ring, opacity/sizeScale uniform 애니메이션
// 1. 알람 맥동 (기존)
if (hasAlarms) {
const tA = (Math.sin(now / 1500 * Math.PI * 2) + 1) / 2;
const normalR = 8 + tA * 6;
const hoverR = 12 + tA * 6;
const pulseLyr = new IconLayer<AisTarget>({
const pulseLyr = new ScatterplotLayer<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) => {
billboard: false,
filled: true,
stroked: false,
radiusUnits: 'pixels',
getRadius: (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;
return isHover ? hoverR : normalR;
},
getColor: (d) => {
getFillColor: (d) => {
const kind = alarmMmsiMap!.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: [selectedMmsi],
},
updateTriggers: { getRadius: [normalR, hoverR] },
});
updated = updated.map((l) =>
// eslint-disable-next-line @typescript-eslint/no-explicit-any

파일 보기

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

파일 보기

@ -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,
@ -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],
@ -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;

파일 보기

@ -41,11 +41,6 @@ import {
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>',
)}`;
/* ── 공통 콜백 인터페이스 ─────────────────────────────── */
interface DeckHoverCallbacks {
@ -91,7 +86,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[] {
@ -382,17 +376,17 @@ 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 }));
}
@ -437,7 +431,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 +440,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',
@ -455,39 +449,38 @@ export function buildMercatorDeckLayers(ctx: MercatorDeckLayerContext): unknown[
getPixelOffset: [0, 16],
getTextAnchor: 'middle',
outlineWidth: 1,
outlineColor: ctx.shipLabelColors?.deckOutline ?? [0, 0, 0, 230],
outlineColor: [0, 0, 0, 230],
}),
);
}
}
/* ─ 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 +510,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],
};
}

파일 보기

@ -90,7 +90,7 @@ export function buildGlobeShipFeature(
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,7 @@
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,12 +12,14 @@ function formatDateTime(ms: number): string {
)}:${pad(date.getSeconds())}`;
}
/** ms → datetime-local input value (KST = UTC+9) */
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);
}
/** datetime-local value (KST) → ISO string */
function fromDateTimeLocalKST(value: string): string {
return `${value}:00+09:00`;
}
@ -45,18 +44,46 @@ const btnBase: React.CSSProperties = {
cursor: 'pointer',
};
interface GlobalTrackReplayPanelProps {
isVesselListOpen?: boolean;
onToggleVesselList?: () => void;
}
export function GlobalTrackReplayPanel() {
const PANEL_WIDTH = 420;
const PANEL_MARGIN = 12;
const PANEL_DEFAULT_TOP = 16;
const PANEL_RIGHT_RESERVED = 520;
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 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 [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,
};
});
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 showLabels = useTrackQueryStore((state) => state.showLabels);
const showTrail = useTrackQueryStore((state) => state.showTrail);
@ -67,7 +94,6 @@ export function GlobalTrackReplayPanel({ isVesselListOpen, onToggleVesselList }:
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);
@ -94,48 +120,92 @@ export function GlobalTrackReplayPanel({ isVesselListOpen, onToggleVesselList }:
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]);
exportTrackCsv(tracks, queryContext);
}, [tracks, queryContext]);
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,
@ -147,79 +217,103 @@ export function GlobalTrackReplayPanel({ isVesselListOpen, onToggleVesselList }:
}}
>
{/* 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}
{error ? (
<div style={{ marginBottom: 8, color: '#fca5a5', fontSize: 12 }}>{error}</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>
) : null}
{/* Vessel count */}
<div style={{ fontSize: 11, color: '#93c5fd', marginBottom: 6 }}>
{tracks.length}
</div>
{/* 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} />
<input
type="datetime-local"
value={editStartTime}
onChange={(e) => setEditStartTime(e.target.value)}
disabled={isLoading || !queryContext}
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} />
<input
type="datetime-local"
value={editEndTime}
onChange={(e) => setEditEndTime(e.target.value)}
disabled={isLoading || !queryContext}
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
type="button"
onClick={handleRequery}
disabled={isLoading || !queryContext || !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' }}>
<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>
@ -227,17 +321,40 @@ export function GlobalTrackReplayPanel({ isVesselListOpen, onToggleVesselList }:
{/* 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={btnBase}
>
{isPlaying ? '일시정지' : '재생'}
</button>
<button type="button" onClick={() => stop()} disabled={tracks.length === 0} style={btnBase}>
<button
type="button"
onClick={() => stop()}
disabled={tracks.length === 0}
style={btnBase}
>
</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>
@ -245,7 +362,15 @@ export function GlobalTrackReplayPanel({ isVesselListOpen, onToggleVesselList }:
{/* 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>
@ -254,57 +379,24 @@ export function GlobalTrackReplayPanel({ isVesselListOpen, onToggleVesselList }:
{/* 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>
<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>
</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,