Compare commits

..

33 커밋

작성자 SHA1 메시지 날짜
257495e7a7 Merge pull request 'docs: 릴리즈 노트 정리 (2026-03-25)' (#62) from release/2026-03-25 into develop 2026-03-25 14:22:59 +09:00
3839c6d224 docs: 릴리즈 노트 정리 (2026-03-25) 2026-03-25 14:22:37 +09:00
a0239c3d44 Merge pull request 'feat(encMap): ENC 전자해도 + 선박 표시 개선' (#61) from experiment/enc-gcnautical into develop 2026-03-25 14:21:39 +09:00
f7ccab18dd docs: 릴리즈 노트 업데이트 2026-03-25 14:20:57 +09:00
d0f67ae803 feat(encMap): gcnautical 타일 서버 기반 ENC 전자해도 + UI 개선
## ENC 베이스맵 (features/encMap/)
- gcnautical 타일 서버 연동 (nautical.json 49개 레이어, 73개 S-52 스프라이트)
- 설정 패널: 12개 레이어 토글, 영역 색상 3종, 수심 색상 5단계
- 배경색 밝기 기반 선박 라벨 색상 자동 전환 (labelColor.ts)
- useMapStyleSettings에 ENC 가드 추가 (스타일 간섭 방지)
- useBaseMapToggle 초기 로드 스킵 (useMapInit과 중복 setStyle 방지)

## 선박 표시 개선
- Globe 원형 halo/outline 제거 — 아이콘 본체만 표시
- Globe 아이콘 스케일 1.3배, 줌아웃 최소 크기 보장 (minzoom 2)
- SDF icon-halo로 테두리 적용 (성능 영향 없음)
- 기타 AIS 투명도 상향 (0.28→0.6 ~ 1.0)
- 선박명 영문 우선 표시 (shipNameRoman > shipNameCn)

## 오버레이 제어 수정
- 연결선/범위/선단 토글 off 시 인터랙티브 오버레이도 비활성
- Globe pair/fc/fleet 레이어: || active 제거 → 토글 우선
- 강조 링/알람 링: shipData→shipLayerData (클러스터링 연동)

## 기본값 변경
- 경고 필터 5개: 초기 false
- 연결선/범위/선단: 초기 false
- 사진 파란 원 아이콘: Globe+Mercator 모두 제거

## 폰트 정리
- Open Sans 폴백 전면 제거 → Noto Sans 단독
- ENC 스타일 fetch 시 text-font 패치

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-25 14:19:28 +09:00
426e075d3f Merge pull request 'docs: 릴리즈 노트 정리 (2026-03-18)' (#59) from release/2026-03-18 into develop 2026-03-18 13:57:01 +09:00
9caac0a9bd docs: 릴리즈 노트 정리 (2026-03-18)
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-18 13:56:42 +09:00
492e5a04c9 Merge pull request 'feat: 선박 그룹 관리 + 우클릭 컨텍스트 메뉴 확장' (#57) from feature/vessel-group-context-menu into develop 2026-03-18 13:55:59 +09:00
0d3d4c0ae6 docs: 릴리즈 노트 업데이트
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-18 13:47:51 +09:00
3524b8c634 chore: 팀 워크플로우 v1.6.1 동기화
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-18 13:46:05 +09:00
94b48945f0 feat(map): 모든 선박 우클릭 컨텍스트 메뉴 — 선명/MMSI 복사
기존 대상선박 전용 우클릭 메뉴를 모든 선박 아이콘으로 확장.
선명 복사, MMSI 복사 항목을 상단에 추가하고,
항적조회는 대상선박(isPermitted)에만 조건부 표시.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-18 13:45:52 +09:00
ed3aef8e2a feat(vesselSelect): 선박 그룹 저장/불러오기 + 컬럼 정렬 + 선택 초기화
다중 항적 조회 모달에서 반복 선택을 줄이기 위해 선박 그룹 관리 기능 추가.
계정별 localStorage 영속화(usePersistedState), 최대 10개 그룹, 동명 덮어쓰기.
그리드 헤더 클릭으로 6개 컬럼 asc/desc 정렬, 푸터에 선택 초기화 버튼 추가.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-18 13:45:35 +09:00
72491ef64c Merge pull request 'docs: 릴리즈 노트 정리 (2026-03-10)' (#55) from release/2026-03-10 into develop 2026-03-10 14:44:50 +09:00
37ee016cb7 docs: 릴리즈 노트 정리 (2026-03-10) 2026-03-10 14:44:22 +09:00
6356d6cb2e Merge pull request 'feat(map): OSM 베이스맵 추가 + 3-way 라디오 그룹 전환' (#54) from feature/multi-track-select into develop 2026-03-10 14:41:42 +09:00
07ad74c56c docs: 릴리즈 노트 업데이트 2026-03-10 14:40:40 +09:00
b9097c91cf feat(map): OSM 베이스맵 추가 + 3-way 라디오 그룹 전환 2026-03-10 14:29:49 +09:00
75737c38cd Merge pull request 'chore: 팀 워크플로우 v1.6.1 동기화 + 관리 파일 .gitignore 전환' (#53) from feature/multi-track-select into develop 2026-03-08 13:24:11 +09:00
f1b0858edf docs: 릴리즈 노트 업데이트
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-08 13:22:16 +09:00
118f13551f chore: 팀 워크플로우 v1.6.1 동기화 + 관리 파일 .gitignore 전환
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-08 13:19:56 +09:00
628d79f2b8 Merge pull request 'feat(vesselSelect): 다중 선박 항적 조회 + 경고 링 개선' (#51) from feature/multi-track-select into develop 2026-03-08 13:01:35 +09:00
baf827657e feat(vesselSelect): 다중 선박 항적 조회 + 경고 링 개선
- 대상 선박 멀티 선택 모달 (features/vesselSelect, widgets/vesselSelect)
  · 업종/상태 필터 분리 + 그룹별 전체 on/off
  · 드래그 선택 (클릭+드래그로 범위 체크/언체크)
  · 기간 프리셋 7/14/21/28일, 최대 조회 28일 제한(초과 시 자동 조정)
  · MAX_VESSEL_SELECT=20, MAX_QUERY_DAYS=28
- trackReplay 확장: beginMultiQuery, queryMultiTrack, 다중 CSV 내보내기
- GlobalTrackReplayPanel: 기간 편집/재조회, 선박 목록 on/off 토글
- 경고 브리딩 효과: filled circle → stroked ring
  · Globe: zoom-interpolated offset 기반 반경
  · Mercator: ScatterplotLayer → IconLayer + SVG ring (깜빡임 해결)
- hideLiveShips 조회 시 기본 체크
- Topbar "다중항적" 버튼 강조 스타일
- 공지사항 id:2 추가

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-08 12:54:20 +09:00
81fb4a2bca feat(shipIcon): 선종별 SVG 아이콘 시스템 도입 + 대상 선박 브리딩 링
gc-wing-simple의 SVG 기반 선종별 아이콘 시스템을 도입하여 기타 AIS 선박을
8종 선종별 색상+형태(이동:화살표/정지:원형)로 구분하고, 대상 선박에는
legacy code 색상 + 브리딩 링 강조 효과를 적용한다.

- shipKind.ts: 선종별 SVG 생성기 + 아이콘 스펙 사전 생성
- Mercator: 기타 AIS 20px SVG IconLayer, 대상 선박 26px SVG IconLayer
- Globe: signalKindCode 기반 색상, 대상 선박 1.3x 크기
- 브리딩 rAF: 시안(선택)/주황(강조) 링, 2000ms 주기
- 범례: "기타 AIS(선종)" 7항목으로 변경
- shipIconCache.ts, SHIP_ICON_MAPPING 삭제

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-08 10:14:33 +09:00
5c5af7e856 Merge pull request 'chore: CLAUDE_BOT_TOKEN 갱신' (#50) from chore/bot-token-fix into develop 2026-03-06 08:04:01 +09:00
2827f7e47d chore: CLAUDE_BOT_TOKEN 갱신 2026-03-06 08:02:27 +09:00
cd30d6d78e Merge pull request 'feat(trackReplay): 항적 기간 조정/재조회 + CSV 내보내기' (#47) from feature/announcement-popup into develop
Reviewed-on: #47
2026-02-25 03:08:24 +09:00
85dc7146be feat(trackReplay): 항적 기간 조정/재조회 + CSV 내보내기
- 패널에 시작/종료 datetime-local 입력 + 재조회 버튼 추가
- TrackQueryContext에 legacy 메타데이터(업종/소유주/허가번호 등) 포함
- CSV 다운로드: points(포인트별 lon/lat/timestamp/speedKnots) + vessel(선박 메타)
- speed는 haversine 거리/시간 기반 계산값(knots)
- trackQueryService에 queryTrackByDateRange 추가
- 패널 체크박스 정리: 가상선박/반복 제거

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-25 03:00:41 +09:00
28988941fc Merge pull request 'feat(announcement): 공지 팝업 + Ocean 수심 커스텀 + 선박명 가독성' (#45) from feature/announcement-popup into develop
Reviewed-on: #45
2026-02-21 00:23:48 +09:00
4f82f6eb64 fix(ocean): 기본값 전환 시 네이티브 색상 복원
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-21 00:22:35 +09:00
b6652815b3 Merge pull request 'feat(announcement): 공지 팝업 모듈 + Ocean 기본값 수정' (#43) from feature/announcement-popup into develop
Reviewed-on: #43
2026-02-21 00:13:20 +09:00
d7834fe1e9 fix: Ocean 수심 커스텀 복원 + 선박명 테두리 개선
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-21 00:10:56 +09:00
3240f6d348 feat(announcement): 공지 팝업 모듈 + Ocean 기본값 수정
- features/announcement/ 자체 완결 블록 (타입, 상수, 훅, 모달 UI)
- useAnnouncementPopup: lastSeenAnnouncementId 기반 계정별 1회 표시
- AnnouncementModal: 업데이트 안내 (Ocean 맵/자유시점/선박사진)
- Ocean DEFAULT_OCEAN_MAP_SETTINGS: depthStops 빈 배열 (네이티브 색상 유지)

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-20 23:43:43 +09:00
cd9311944b Merge pull request 'feat(ocean-map): Ocean 전용 지도 모듈 추가' (#42) from feature/ocean-map-module into develop
Reviewed-on: #42
2026-02-20 23:16:47 +09:00
76개의 변경된 파일3600개의 추가작업 그리고 1757개의 파일을 삭제

파일 보기

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

파일 보기

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

파일 보기

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

파일 보기

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

파일 보기

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

파일 보기

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

파일 보기

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

파일 보기

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

파일 보기

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

파일 보기

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

파일 보기

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

파일 보기

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

파일 보기

@ -1,123 +0,0 @@
---
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 생성만, 세부 옵션 지원 (상세 제어)

파일 보기

@ -1,92 +0,0 @@
---
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(-)
```

파일 보기

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

파일 보기

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

파일 보기

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

11
.gitignore vendored
파일 보기

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

파일 보기

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

파일 보기

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

파일 보기

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

파일 보기

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

파일 보기

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

파일 보기

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

파일 보기

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

파일 보기

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

파일 보기

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

파일 보기

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

파일 보기

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

파일 보기

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

파일 보기

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

파일 보기

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

파일 보기

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

파일 보기

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

파일 보기

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

파일 보기

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

파일 보기

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

파일 보기

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

파일 보기

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

파일 보기

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

파일 보기

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

파일 보기

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

파일 보기

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

파일 보기

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

파일 보기

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

파일 보기

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

파일 보기

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

파일 보기

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

파일 보기

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

파일 보기

@ -14,10 +14,7 @@ const OVERLAY_FC_TRANSFER_RGB = OVERLAY_RGB.fcTransfer;
const OVERLAY_FLEET_RANGE_RGB = OVERLAY_RGB.fleetRange; const OVERLAY_FLEET_RANGE_RGB = OVERLAY_RGB.fleetRange;
const OVERLAY_SUSPICIOUS_RGB = OVERLAY_RGB.suspicious; const OVERLAY_SUSPICIOUS_RGB = OVERLAY_RGB.suspicious;
// ── Ship icon mapping (Deck.gl IconLayer) ── // Ship icon mapping removed — now using shipKind.ts SVG-based icons
// Canonical source: shared/lib/map/mapConstants.ts (re-exported for local usage)
export { SHIP_ICON_MAPPING } from '../../shared/lib/map/mapConstants';
// ── Ship constants ── // ── Ship constants ──
@ -47,14 +44,20 @@ export const GLOBE_OUTLINE_OTHER = 'rgba(160,175,195,0.55)';
// ── Flat map icon sizes ── // ── Flat map icon sizes ──
export const FLAT_SHIP_ICON_SIZE = 19; export const FLAT_OTHER_SHIP_SIZE = 20;
export const FLAT_SHIP_ICON_SIZE_SELECTED = 28; export const FLAT_TARGET_SHIP_SIZE = 26;
export const FLAT_SHIP_ICON_SIZE_HIGHLIGHTED = 25;
export const FLAT_LEGACY_HALO_RADIUS = 14; export const FLAT_LEGACY_HALO_RADIUS = 14;
export const FLAT_LEGACY_HALO_RADIUS_SELECTED = 18; export const FLAT_LEGACY_HALO_RADIUS_SELECTED = 18;
export const FLAT_LEGACY_HALO_RADIUS_HIGHLIGHTED = 16;
export const EMPTY_MMSI_SET = new Set<number>(); export const EMPTY_MMSI_SET = new Set<number>();
// ── 대상 선박 브리딩 애니메이션 ──
export const HALO_BREATHE_PERIOD_MS = 2000;
export const HALO_BREATHE_SELECTED_R_MIN = 16;
export const HALO_BREATHE_SELECTED_R_MAX = 22;
export const HALO_BREATHE_HIGHLIGHTED_R_MIN = 14;
export const HALO_BREATHE_HIGHLIGHTED_R_MAX = 19;
// ── Deck.gl view ID ── // ── Deck.gl view ID ──
export const DECK_VIEW_ID = 'mapbox'; export const DECK_VIEW_ID = 'mapbox';

파일 보기

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

파일 보기

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

파일 보기

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

파일 보기

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

파일 보기

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

파일 보기

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

파일 보기

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

파일 보기

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

파일 보기

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

파일 보기

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

파일 보기

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

파일 보기

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

파일 보기

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

파일 보기

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

파일 보기

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

파일 보기

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

파일 보기

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

파일 보기

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

파일 보기

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

파일 보기

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

파일 보기

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

45
docs/RELEASE-NOTES.md Normal file
파일 보기

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