develop #44
123
.claude/skills/mr/SKILL.md
Normal file
123
.claude/skills/mr/SKILL.md
Normal file
@ -0,0 +1,123 @@
|
||||
---
|
||||
name: mr
|
||||
description: 커밋 + 푸시 + Gitea MR을 한 번에 생성합니다
|
||||
user-invocable: true
|
||||
argument-hint: "[target-branch: develop|main] (기본: develop)"
|
||||
allowed-tools: "Bash, Read, Grep"
|
||||
---
|
||||
|
||||
현재 브랜치의 변경 사항을 커밋+푸시하고, Gitea에 MR을 생성합니다.
|
||||
타겟 브랜치: $ARGUMENTS (기본: develop)
|
||||
|
||||
## 수행 단계
|
||||
|
||||
### 1. 사전 검증
|
||||
|
||||
```bash
|
||||
# 현재 브랜치 확인 (main/develop이면 중단)
|
||||
BRANCH=$(git branch --show-current)
|
||||
|
||||
# Gitea remote URL에서 owner/repo 추출
|
||||
REMOTE_URL=$(git remote get-url origin)
|
||||
```
|
||||
|
||||
- 현재 브랜치가 `main` 또는 `develop`이면: "feature 브랜치에서 실행해주세요" 안내 후 종료
|
||||
- GITEA_TOKEN 환경변수 확인 (없으면 설정 안내)
|
||||
|
||||
### 2. 커밋 + 푸시 (변경 사항이 있을 때만)
|
||||
|
||||
```bash
|
||||
git status --short
|
||||
```
|
||||
|
||||
**커밋되지 않은 변경이 있으면**:
|
||||
- 변경 범위(파일 목록, 추가/수정/삭제) 요약 표시
|
||||
- Conventional Commits 형식 커밋 메시지 자동 생성
|
||||
- **사용자 확인** (AskUserQuestion): 커밋 메시지 수락/수정/취소
|
||||
- 수락 시: `git add -A` → `git commit` → `git push`
|
||||
|
||||
**변경이 없으면**:
|
||||
- 이미 커밋된 내용으로 MR 생성 진행
|
||||
- 리모트에 push되지 않은 커밋이 있으면 `git push`
|
||||
|
||||
### 3. MR 대상 브랜치 결정
|
||||
|
||||
타겟 브랜치 후보를 분석하여 표시:
|
||||
|
||||
```bash
|
||||
# develop과의 차이
|
||||
git log develop..HEAD --oneline 2>/dev/null
|
||||
# main과의 차이
|
||||
git log main..HEAD --oneline 2>/dev/null
|
||||
```
|
||||
|
||||
**사용자 확인** (AskUserQuestion):
|
||||
- **질문**: "MR 타겟 브랜치를 선택하세요"
|
||||
- 옵션 1: develop (추천, N건 커밋 차이)
|
||||
- 옵션 2: main (N건 커밋 차이)
|
||||
- 옵션 3: 취소
|
||||
|
||||
인자($ARGUMENTS)로 브랜치가 지정되었으면 확인 없이 바로 진행.
|
||||
|
||||
### 4. MR 정보 구성
|
||||
|
||||
```bash
|
||||
# 커밋 목록
|
||||
git log {target}..HEAD --oneline
|
||||
# 변경 파일 통계
|
||||
git diff {target}..HEAD --stat
|
||||
```
|
||||
|
||||
- **제목**: 커밋이 1개면 커밋 메시지 사용, 여러 개면 브랜치명에서 추출
|
||||
- `feature/ISSUE-42-user-login` → `feat: ISSUE-42 user-login`
|
||||
- `bugfix/fix-timeout` → `fix: fix-timeout`
|
||||
- **본문**:
|
||||
```markdown
|
||||
## 변경 사항
|
||||
- (커밋 목록 기반 자동 생성)
|
||||
|
||||
## 관련 이슈
|
||||
- closes #이슈번호 (브랜치명에서 추출, 없으면 생략)
|
||||
|
||||
## 테스트
|
||||
- [ ] 빌드 성공 확인
|
||||
- [ ] 기존 테스트 통과
|
||||
```
|
||||
|
||||
### 5. Gitea API로 MR 생성
|
||||
|
||||
```bash
|
||||
# remote URL에서 Gitea 호스트, owner, repo 파싱
|
||||
# 예: https://gitea.gc-si.dev/gc/my-project.git → host=gitea.gc-si.dev, owner=gc, repo=my-project
|
||||
|
||||
curl -X POST "https://{host}/api/v1/repos/{owner}/{repo}/pulls" \
|
||||
-H "Authorization: token ${GITEA_TOKEN}" \
|
||||
-H "Content-Type: application/json" \
|
||||
-d '{
|
||||
"title": "MR 제목",
|
||||
"body": "MR 본문",
|
||||
"head": "현재브랜치",
|
||||
"base": "타겟브랜치"
|
||||
}'
|
||||
```
|
||||
|
||||
### 6. 결과 출력
|
||||
|
||||
```
|
||||
✅ MR 생성 완료
|
||||
브랜치: feature/my-branch → develop
|
||||
MR: https://gitea.gc-si.dev/gc/my-project/pulls/42
|
||||
커밋: 3건, 파일: 5개 변경
|
||||
|
||||
다음 단계: 리뷰어 지정 → 승인 대기 → 머지
|
||||
```
|
||||
|
||||
## 필요 환경변수
|
||||
|
||||
- `GITEA_TOKEN`: Gitea API 접근 토큰
|
||||
- 없으면: "Gitea 토큰이 필요합니다. Settings → Applications에서 생성하세요" 안내
|
||||
|
||||
## 기존 /create-mr과의 차이
|
||||
|
||||
- `/mr`: 커밋+푸시 포함, 빠른 실행 (일상적 사용)
|
||||
- `/create-mr`: MR 생성만, 세부 옵션 지원 (상세 제어)
|
||||
92
.claude/skills/push/SKILL.md
Normal file
92
.claude/skills/push/SKILL.md
Normal file
@ -0,0 +1,92 @@
|
||||
---
|
||||
name: push
|
||||
description: 변경 사항을 확인하고 커밋 + 푸시합니다
|
||||
user-invocable: true
|
||||
argument-hint: "[commit-message] (생략 시 자동 생성)"
|
||||
allowed-tools: "Bash, Read, Grep"
|
||||
---
|
||||
|
||||
현재 브랜치의 변경 사항을 확인하고, 사용자 승인 후 커밋 + 푸시합니다.
|
||||
커밋 메시지 인자: $ARGUMENTS (생략 시 변경 내용 기반 자동 생성)
|
||||
|
||||
## 수행 단계
|
||||
|
||||
### 1. 현재 상태 수집
|
||||
|
||||
```bash
|
||||
# 현재 브랜치
|
||||
git branch --show-current
|
||||
|
||||
# 커밋되지 않은 변경 사항
|
||||
git status --short
|
||||
|
||||
# 변경 통계
|
||||
git diff --stat
|
||||
git diff --cached --stat
|
||||
```
|
||||
|
||||
### 2. 변경 범위 표시
|
||||
|
||||
사용자에게 다음 정보를 **표 형태**로 요약하여 보여준다:
|
||||
|
||||
- 현재 브랜치명
|
||||
- 변경된 파일 목록 (추가/수정/삭제 구분)
|
||||
- staged vs unstaged 구분
|
||||
- 변경 라인 수 요약
|
||||
|
||||
변경 사항이 없으면 "커밋할 변경 사항이 없습니다" 출력 후 종료.
|
||||
|
||||
### 3. 커밋 메시지 결정
|
||||
|
||||
**인자가 있는 경우** ($ARGUMENTS가 비어있지 않으면):
|
||||
- 전달받은 메시지를 커밋 메시지로 사용
|
||||
- Conventional Commits 형식인지 검증 (아니면 자동 보정 제안)
|
||||
|
||||
**인자가 없는 경우**:
|
||||
- 변경 내용을 분석하여 Conventional Commits 형식 메시지 자동 생성
|
||||
- 형식: `type(scope): 한국어 설명`
|
||||
- type 판단 기준:
|
||||
- 새 파일 추가 → `feat`
|
||||
- 기존 파일 수정 → `fix` 또는 `refactor`
|
||||
- 테스트 파일 → `test`
|
||||
- 설정/빌드 파일 → `chore`
|
||||
- 문서 파일 → `docs`
|
||||
|
||||
### 4. 사용자 확인
|
||||
|
||||
AskUserQuestion으로 다음을 확인:
|
||||
|
||||
**질문**: "다음 내용으로 커밋하시겠습니까?"
|
||||
- 옵션 1: 제안된 메시지로 커밋 (추천)
|
||||
- 옵션 2: 메시지 수정 (Other 입력)
|
||||
- 옵션 3: 취소
|
||||
|
||||
### 5. 커밋 + 푸시 실행
|
||||
|
||||
사용자가 수락하면:
|
||||
|
||||
```bash
|
||||
# 모든 변경 사항 스테이징 (untracked 포함)
|
||||
# 단, .env, secrets/ 등 민감 파일은 제외
|
||||
git add -A
|
||||
|
||||
# 커밋 (.githooks/commit-msg가 형식 검증)
|
||||
git commit -m "커밋메시지"
|
||||
|
||||
# 푸시 (리모트 트래킹 없으면 -u 추가)
|
||||
git push origin $(git branch --show-current)
|
||||
```
|
||||
|
||||
**주의사항**:
|
||||
- `git add` 전에 `.env`, `*.key`, `secrets/` 등 민감 파일이 포함되어 있으면 경고
|
||||
- pre-commit hook 실패 시 에러 메시지 표시 후 수동 해결 안내
|
||||
- 리모트에 브랜치가 없으면 `git push -u origin {branch}` 사용
|
||||
|
||||
### 6. 결과 출력
|
||||
|
||||
```
|
||||
✅ 푸시 완료
|
||||
브랜치: feature/my-branch
|
||||
커밋: abc1234 feat(auth): 로그인 검증 로직 추가
|
||||
변경: 3 files changed, 45 insertions(+), 12 deletions(-)
|
||||
```
|
||||
134
.claude/skills/release/SKILL.md
Normal file
134
.claude/skills/release/SKILL.md
Normal file
@ -0,0 +1,134 @@
|
||||
---
|
||||
name: release
|
||||
description: develop에서 main으로 릴리즈 MR을 생성합니다
|
||||
user-invocable: true
|
||||
argument-hint: ""
|
||||
allowed-tools: "Bash, Read, Grep"
|
||||
---
|
||||
|
||||
develop 브랜치와 원격 동기화를 확인하고, develop → main 릴리즈 MR을 생성합니다.
|
||||
|
||||
## 수행 단계
|
||||
|
||||
### 1. 사전 검증
|
||||
|
||||
```bash
|
||||
# Gitea remote URL에서 owner/repo 추출
|
||||
REMOTE_URL=$(git remote get-url origin)
|
||||
|
||||
# GITEA_TOKEN 확인
|
||||
echo $GITEA_TOKEN
|
||||
```
|
||||
|
||||
- GITEA_TOKEN 환경변수 확인 (없으면 설정 안내 후 종료)
|
||||
- 커밋되지 않은 변경 사항이 있으면 경고 ("먼저 /push로 커밋하세요")
|
||||
|
||||
### 2. develop 브랜치 동기화 확인
|
||||
|
||||
```bash
|
||||
# 최신 원격 상태 가져오기
|
||||
git fetch origin
|
||||
|
||||
# 로컬 develop과 origin/develop 비교
|
||||
LOCAL=$(git rev-parse develop 2>/dev/null)
|
||||
REMOTE=$(git rev-parse origin/develop 2>/dev/null)
|
||||
BASE=$(git merge-base develop origin/develop 2>/dev/null)
|
||||
```
|
||||
|
||||
**동기화 상태 판단:**
|
||||
|
||||
| 상태 | 조건 | 행동 |
|
||||
|------|------|------|
|
||||
| 동일 | LOCAL == REMOTE | 바로 MR 생성 진행 |
|
||||
| 로컬 뒤처짐 | LOCAL == BASE, LOCAL != REMOTE | "origin/develop에 새 커밋이 있습니다. `git pull origin develop` 후 다시 시도하세요" 안내 |
|
||||
| 로컬 앞섬 | REMOTE == BASE, LOCAL != REMOTE | "로컬에 push되지 않은 커밋이 있습니다. `git push origin develop` 먼저 실행하시겠습니까?" 확인 |
|
||||
| 분기됨 | 그 외 | "로컬과 원격 develop이 분기되었습니다. 수동으로 해결해주세요" 경고 후 종료 |
|
||||
|
||||
**로컬 앞섬 상태에서 사용자가 push 수락하면:**
|
||||
```bash
|
||||
git push origin develop
|
||||
```
|
||||
|
||||
### 3. develop → main 차이 분석
|
||||
|
||||
```bash
|
||||
# main 대비 develop의 새 커밋
|
||||
git log main..origin/develop --oneline
|
||||
|
||||
# 변경 파일 통계
|
||||
git diff main..origin/develop --stat
|
||||
|
||||
# 커밋 수
|
||||
git rev-list --count main..origin/develop
|
||||
```
|
||||
|
||||
차이가 없으면 "develop과 main이 동일합니다. 릴리즈할 변경이 없습니다" 출력 후 종료.
|
||||
|
||||
### 4. MR 정보 구성 + 사용자 확인
|
||||
|
||||
**제목 자동 생성:**
|
||||
```
|
||||
release: YYYY-MM-DD (N건 커밋)
|
||||
```
|
||||
|
||||
**본문 자동 생성:**
|
||||
```markdown
|
||||
## 릴리즈 내용
|
||||
- (develop→main 커밋 목록, Conventional Commits type별 그룹핑)
|
||||
|
||||
### 새 기능 (feat)
|
||||
- feat(auth): 로그인 검증 로직 추가
|
||||
- feat(batch): 배치 스케줄러 개선
|
||||
|
||||
### 버그 수정 (fix)
|
||||
- fix(api): 타임아웃 처리 수정
|
||||
|
||||
### 기타
|
||||
- chore: 의존성 업데이트
|
||||
|
||||
## 변경 파일
|
||||
- N files changed, +M insertions, -K deletions
|
||||
|
||||
## 테스트
|
||||
- [ ] develop 브랜치 빌드 성공 확인
|
||||
- [ ] 주요 기능 동작 확인
|
||||
```
|
||||
|
||||
**사용자 확인** (AskUserQuestion):
|
||||
- **질문**: "다음 내용으로 릴리즈 MR을 생성하시겠습니까?"
|
||||
- 옵션 1: 생성 (추천)
|
||||
- 옵션 2: 제목/본문 수정 (Other 입력)
|
||||
- 옵션 3: 취소
|
||||
|
||||
### 5. Gitea API로 릴리즈 MR 생성
|
||||
|
||||
```bash
|
||||
curl -X POST "https://{host}/api/v1/repos/{owner}/{repo}/pulls" \
|
||||
-H "Authorization: token ${GITEA_TOKEN}" \
|
||||
-H "Content-Type: application/json" \
|
||||
-d '{
|
||||
"title": "release: 2026-02-19 (12건 커밋)",
|
||||
"body": "릴리즈 본문",
|
||||
"head": "develop",
|
||||
"base": "main",
|
||||
"labels": []
|
||||
}'
|
||||
```
|
||||
|
||||
### 6. 결과 출력
|
||||
|
||||
```
|
||||
✅ 릴리즈 MR 생성 완료
|
||||
브랜치: develop → main
|
||||
MR: https://gitea.gc-si.dev/gc/my-project/pulls/50
|
||||
커밋: 12건, 파일: 28개 변경
|
||||
|
||||
다음 단계:
|
||||
1. 리뷰어 지정 (main 브랜치는 1명 이상 리뷰 필수)
|
||||
2. 승인 후 머지
|
||||
3. CI/CD 자동 배포 확인 (설정된 경우)
|
||||
```
|
||||
|
||||
## 필요 환경변수
|
||||
|
||||
- `GITEA_TOKEN`: Gitea API 접근 토큰
|
||||
@ -18,3 +18,4 @@
|
||||
@import "./styles/components/auth.css";
|
||||
@import "./styles/components/weather.css";
|
||||
@import "./styles/components/weather-overlay.css";
|
||||
@import "./styles/components/announcement.css";
|
||||
|
||||
129
apps/web/src/app/styles/components/announcement.css
Normal file
129
apps/web/src/app/styles/components/announcement.css
Normal file
@ -0,0 +1,129 @@
|
||||
/* ── Announcement modal ─────────────────────────────────────────── */
|
||||
|
||||
.announcement-modal {
|
||||
position: fixed;
|
||||
inset: 0;
|
||||
z-index: 1050;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
background: rgba(0, 0, 0, 0.8);
|
||||
backdrop-filter: blur(4px);
|
||||
}
|
||||
|
||||
.announcement-modal__content {
|
||||
position: relative;
|
||||
width: min(88vw, 480px);
|
||||
max-height: min(85vh, 560px);
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
background: var(--wing-glass-dense);
|
||||
border: 1px solid var(--border);
|
||||
border-radius: 12px;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.announcement-modal__header {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
padding: 14px 16px 12px;
|
||||
border-bottom: 1px solid var(--wing-subtle);
|
||||
}
|
||||
|
||||
.announcement-modal__title {
|
||||
font-size: 14px;
|
||||
font-weight: 700;
|
||||
color: var(--text);
|
||||
}
|
||||
|
||||
.announcement-modal__close {
|
||||
background: none;
|
||||
border: none;
|
||||
color: var(--muted);
|
||||
font-size: 18px;
|
||||
cursor: pointer;
|
||||
padding: 2px 6px;
|
||||
line-height: 1;
|
||||
}
|
||||
|
||||
.announcement-modal__close:hover {
|
||||
color: var(--text);
|
||||
}
|
||||
|
||||
.announcement-modal__body {
|
||||
flex: 1;
|
||||
overflow-y: auto;
|
||||
padding: 12px 16px;
|
||||
}
|
||||
|
||||
.announcement-modal__section {
|
||||
margin-bottom: 16px;
|
||||
}
|
||||
|
||||
.announcement-modal__section:last-child {
|
||||
margin-bottom: 0;
|
||||
}
|
||||
|
||||
.announcement-modal__section-header {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
margin-bottom: 10px;
|
||||
}
|
||||
|
||||
.announcement-modal__section-title {
|
||||
font-size: 12px;
|
||||
font-weight: 600;
|
||||
color: var(--text);
|
||||
}
|
||||
|
||||
.announcement-modal__date {
|
||||
font-size: 10px;
|
||||
color: var(--muted);
|
||||
}
|
||||
|
||||
.announcement-modal__list {
|
||||
list-style: none;
|
||||
margin: 0;
|
||||
padding: 0;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 10px;
|
||||
}
|
||||
|
||||
.announcement-modal__item {
|
||||
display: flex;
|
||||
gap: 10px;
|
||||
align-items: flex-start;
|
||||
padding: 8px 10px;
|
||||
background: var(--wing-card-alpha);
|
||||
border: 1px solid var(--wing-subtle);
|
||||
border-radius: 8px;
|
||||
}
|
||||
|
||||
.announcement-modal__icon {
|
||||
font-size: 18px;
|
||||
flex-shrink: 0;
|
||||
line-height: 1.2;
|
||||
}
|
||||
|
||||
.announcement-modal__item-title {
|
||||
font-size: 12px;
|
||||
font-weight: 600;
|
||||
color: var(--text);
|
||||
margin-bottom: 2px;
|
||||
}
|
||||
|
||||
.announcement-modal__item-desc {
|
||||
font-size: 10px;
|
||||
color: var(--muted);
|
||||
line-height: 1.5;
|
||||
}
|
||||
|
||||
.announcement-modal__footer {
|
||||
display: flex;
|
||||
justify-content: center;
|
||||
padding: 12px 16px;
|
||||
border-top: 1px solid var(--wing-subtle);
|
||||
}
|
||||
34
apps/web/src/features/announcement/data/announcements.ts
Normal file
34
apps/web/src/features/announcement/data/announcements.ts
Normal file
@ -0,0 +1,34 @@
|
||||
import type { Announcement } from '../model/types';
|
||||
|
||||
/**
|
||||
* 공지 데이터.
|
||||
* 현재는 상수로 관리하며, 향후 API로 대체 시 이 파일 대신 api/ 모듈을 사용한다.
|
||||
* id는 단조 증가해야 한다 (새 공지 추가 시 마지막 id + 1).
|
||||
*/
|
||||
export const ANNOUNCEMENTS: Announcement[] = [
|
||||
{
|
||||
id: 1,
|
||||
title: 'Wing Fleet Dashboard 업데이트',
|
||||
date: '2026-02-20',
|
||||
items: [
|
||||
{
|
||||
icon: '🌊',
|
||||
title: 'Ocean 전용 지도',
|
||||
description: 'MapTiler Ocean 스타일 기반의 해양 특화 지도가 추가되었습니다. 수심 색상, 등심선, 해저 지형명을 설정 패널에서 세부 조정할 수 있습니다.',
|
||||
},
|
||||
{
|
||||
icon: '🎥',
|
||||
title: '자유시점 모드',
|
||||
description: '지도 표시 설정의 "자유 시점" 토글로 카메라 회전/틸트를 허용할 수 있습니다. 평면지도와 3D지도에서 각각 독립적으로 설정됩니다.',
|
||||
},
|
||||
{
|
||||
icon: '📷',
|
||||
title: '선박 사진 조회',
|
||||
description: '지도 위 선박을 클릭하면 사진이 있는 선박의 이미지를 갤러리로 확인할 수 있습니다.',
|
||||
},
|
||||
],
|
||||
},
|
||||
];
|
||||
|
||||
/** 현재 최신 공지 ID */
|
||||
export const LATEST_ANNOUNCEMENT_ID = ANNOUNCEMENTS[ANNOUNCEMENTS.length - 1]?.id ?? 0;
|
||||
@ -0,0 +1,33 @@
|
||||
import { useCallback, useMemo } from 'react';
|
||||
import { usePersistedState } from '../../../shared/hooks';
|
||||
import { ANNOUNCEMENTS, LATEST_ANNOUNCEMENT_ID } from '../data/announcements';
|
||||
|
||||
/**
|
||||
* 공지 팝업 표시 여부를 판단하고 확인 처리를 담당하는 훅.
|
||||
*
|
||||
* localStorage 키: `wing:${userId}:lastSeenAnnouncementId`
|
||||
* 새 공지(LATEST_ANNOUNCEMENT_ID)가 저장값보다 크면 팝업을 표시한다.
|
||||
*/
|
||||
export function useAnnouncementPopup(userId: number | null) {
|
||||
const [lastSeenId, setLastSeenId] = usePersistedState<number>(
|
||||
userId,
|
||||
'lastSeenAnnouncementId',
|
||||
0,
|
||||
);
|
||||
|
||||
/** 미확인 공지가 있는지 여부 */
|
||||
const hasUnread = LATEST_ANNOUNCEMENT_ID > lastSeenId;
|
||||
|
||||
/** 미확인 공지 목록 */
|
||||
const unreadAnnouncements = useMemo(
|
||||
() => ANNOUNCEMENTS.filter((a) => a.id > lastSeenId),
|
||||
[lastSeenId],
|
||||
);
|
||||
|
||||
/** "확인" 버튼 클릭 시 호출 — 최신 공지 ID를 저장 */
|
||||
const acknowledge = useCallback(() => {
|
||||
setLastSeenId(LATEST_ANNOUNCEMENT_ID);
|
||||
}, [setLastSeenId]);
|
||||
|
||||
return { hasUnread, unreadAnnouncements, acknowledge };
|
||||
}
|
||||
4
apps/web/src/features/announcement/index.ts
Normal file
4
apps/web/src/features/announcement/index.ts
Normal file
@ -0,0 +1,4 @@
|
||||
export type { Announcement, AnnouncementItem } from './model/types';
|
||||
export { ANNOUNCEMENTS, LATEST_ANNOUNCEMENT_ID } from './data/announcements';
|
||||
export { useAnnouncementPopup } from './hooks/useAnnouncementPopup';
|
||||
export { AnnouncementModal } from './ui/AnnouncementModal';
|
||||
21
apps/web/src/features/announcement/model/types.ts
Normal file
21
apps/web/src/features/announcement/model/types.ts
Normal file
@ -0,0 +1,21 @@
|
||||
/** 공지 항목 하나 */
|
||||
export interface AnnouncementItem {
|
||||
/** 아이콘 (emoji) */
|
||||
icon: string;
|
||||
/** 항목 제목 */
|
||||
title: string;
|
||||
/** 항목 상세 설명 */
|
||||
description?: string;
|
||||
}
|
||||
|
||||
/** 공지 전체 구조 */
|
||||
export interface Announcement {
|
||||
/** 고유 식별자 — 단조 증가 (향후 서버 PK와 매핑) */
|
||||
id: number;
|
||||
/** 공지 제목 (모달 헤더) */
|
||||
title: string;
|
||||
/** 표시용 날짜 */
|
||||
date: string;
|
||||
/** 공지 항목 리스트 */
|
||||
items: AnnouncementItem[];
|
||||
}
|
||||
66
apps/web/src/features/announcement/ui/AnnouncementModal.tsx
Normal file
66
apps/web/src/features/announcement/ui/AnnouncementModal.tsx
Normal file
@ -0,0 +1,66 @@
|
||||
import { useEffect } from 'react';
|
||||
import { Button } from '@wing/ui';
|
||||
import type { Announcement } from '../model/types';
|
||||
|
||||
interface AnnouncementModalProps {
|
||||
announcements: Announcement[];
|
||||
onConfirm: () => void;
|
||||
}
|
||||
|
||||
export function AnnouncementModal({ announcements, onConfirm }: AnnouncementModalProps) {
|
||||
useEffect(() => {
|
||||
const onKey = (e: KeyboardEvent) => {
|
||||
if (e.key === 'Escape') onConfirm();
|
||||
};
|
||||
window.addEventListener('keydown', onKey);
|
||||
return () => window.removeEventListener('keydown', onKey);
|
||||
}, [onConfirm]);
|
||||
|
||||
return (
|
||||
<div className="announcement-modal" onClick={onConfirm}>
|
||||
<div className="announcement-modal__content" onClick={(e) => e.stopPropagation()}>
|
||||
<div className="announcement-modal__header">
|
||||
<span className="announcement-modal__title">업데이트 안내</span>
|
||||
<button
|
||||
className="announcement-modal__close"
|
||||
onClick={onConfirm}
|
||||
type="button"
|
||||
aria-label="닫기"
|
||||
>
|
||||
✕
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div className="announcement-modal__body">
|
||||
{announcements.map((announcement) => (
|
||||
<div key={announcement.id} className="announcement-modal__section">
|
||||
<div className="announcement-modal__section-header">
|
||||
<span className="announcement-modal__section-title">{announcement.title}</span>
|
||||
<span className="announcement-modal__date">{announcement.date}</span>
|
||||
</div>
|
||||
<ul className="announcement-modal__list">
|
||||
{announcement.items.map((item, idx) => (
|
||||
<li key={idx} className="announcement-modal__item">
|
||||
<span className="announcement-modal__icon">{item.icon}</span>
|
||||
<div>
|
||||
<div className="announcement-modal__item-title">{item.title}</div>
|
||||
{item.description && (
|
||||
<div className="announcement-modal__item-desc">{item.description}</div>
|
||||
)}
|
||||
</div>
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
|
||||
<div className="announcement-modal__footer">
|
||||
<Button variant="primary" size="md" onClick={onConfirm}>
|
||||
확인
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
193
apps/web/src/features/oceanMap/hooks/useOceanMapSettings.ts
Normal file
193
apps/web/src/features/oceanMap/hooks/useOceanMapSettings.ts
Normal file
@ -0,0 +1,193 @@
|
||||
import { useEffect, useRef, type MutableRefObject } from 'react';
|
||||
import maplibregl from 'maplibre-gl';
|
||||
import type { OceanMapSettings, OceanDepthStop, OceanDepthLabelSize, OceanLabelLanguage } from '../model/types';
|
||||
import type { BaseMapId } from '../../../widgets/map3d/types';
|
||||
import { kickRepaint, onMapStyleReady } from '../../../widgets/map3d/lib/mapCore';
|
||||
import { discoverOceanLayers } from '../lib/oceanLayerIds';
|
||||
|
||||
/* ── Depth font size presets ──────────────────────────────────────── */
|
||||
const OCEAN_DEPTH_FONT_SIZE: Record<OceanDepthLabelSize, unknown[]> = {
|
||||
small: ['interpolate', ['linear'], ['zoom'], 5, 8, 8, 10, 11, 12],
|
||||
medium: ['interpolate', ['linear'], ['zoom'], 5, 10, 8, 12, 11, 15],
|
||||
large: ['interpolate', ['linear'], ['zoom'], 5, 12, 8, 15, 11, 18],
|
||||
};
|
||||
|
||||
/* ── Apply functions (Ocean 전용 — enhanced 코드와 공유 없음) ────── */
|
||||
|
||||
function applyOceanDepthColors(map: maplibregl.Map, layers: string[], stops: OceanDepthStop[], opacity: number) {
|
||||
if (layers.length === 0) return;
|
||||
const depth = ['to-number', ['get', 'depth']];
|
||||
const sorted = [...stops].sort((a, b) => a.depth - b.depth);
|
||||
if (sorted.length < 2) return;
|
||||
|
||||
const expr: unknown[] = ['interpolate', ['linear'], depth];
|
||||
for (const s of sorted) {
|
||||
expr.push(s.depth, s.color);
|
||||
}
|
||||
|
||||
for (const id of layers) {
|
||||
if (!map.getLayer(id)) continue;
|
||||
try {
|
||||
map.setPaintProperty(id, 'fill-color', expr as never);
|
||||
map.setPaintProperty(id, 'fill-opacity', opacity);
|
||||
} catch {
|
||||
// ignore
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
function applyOceanContourStyle(
|
||||
map: maplibregl.Map,
|
||||
layers: string[],
|
||||
visible: boolean,
|
||||
color: string,
|
||||
opacity: number,
|
||||
width: number,
|
||||
) {
|
||||
for (const id of layers) {
|
||||
if (!map.getLayer(id)) continue;
|
||||
try {
|
||||
map.setLayoutProperty(id, 'visibility', visible ? 'visible' : 'none');
|
||||
if (visible) {
|
||||
map.setPaintProperty(id, 'line-color', color);
|
||||
map.setPaintProperty(id, 'line-opacity', opacity);
|
||||
map.setPaintProperty(id, 'line-width', width);
|
||||
}
|
||||
} catch {
|
||||
// ignore
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
function applyOceanDepthLabels(
|
||||
map: maplibregl.Map,
|
||||
layers: string[],
|
||||
visible: boolean,
|
||||
color: string,
|
||||
size: OceanDepthLabelSize,
|
||||
) {
|
||||
const sizeExpr = OCEAN_DEPTH_FONT_SIZE[size];
|
||||
for (const id of layers) {
|
||||
if (!map.getLayer(id)) continue;
|
||||
try {
|
||||
map.setLayoutProperty(id, 'visibility', visible ? 'visible' : 'none');
|
||||
if (visible) {
|
||||
map.setPaintProperty(id, 'text-color', color);
|
||||
map.setLayoutProperty(id, 'text-size', sizeExpr);
|
||||
}
|
||||
} catch {
|
||||
// ignore
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
function applyOceanHillshade(
|
||||
map: maplibregl.Map,
|
||||
layers: string[],
|
||||
visible: boolean,
|
||||
exaggeration: number,
|
||||
color: string,
|
||||
) {
|
||||
for (const id of layers) {
|
||||
if (!map.getLayer(id)) continue;
|
||||
try {
|
||||
map.setLayoutProperty(id, 'visibility', visible ? 'visible' : 'none');
|
||||
if (visible) {
|
||||
map.setPaintProperty(id, 'hillshade-exaggeration', exaggeration);
|
||||
map.setPaintProperty(id, 'hillshade-shadow-color', color);
|
||||
}
|
||||
} catch {
|
||||
// ignore
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
function applyOceanLandformLabels(
|
||||
map: maplibregl.Map,
|
||||
layers: string[],
|
||||
visible: boolean,
|
||||
color: string,
|
||||
) {
|
||||
for (const id of layers) {
|
||||
if (!map.getLayer(id)) continue;
|
||||
try {
|
||||
map.setLayoutProperty(id, 'visibility', visible ? 'visible' : 'none');
|
||||
if (visible) {
|
||||
map.setPaintProperty(id, 'text-color', color);
|
||||
}
|
||||
} catch {
|
||||
// ignore
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
function applyOceanBackground(map: maplibregl.Map, layers: string[], color: string) {
|
||||
for (const id of layers) {
|
||||
if (!map.getLayer(id)) continue;
|
||||
try {
|
||||
map.setPaintProperty(id, 'background-color', color);
|
||||
} catch {
|
||||
// ignore
|
||||
}
|
||||
}
|
||||
try {
|
||||
map.getCanvas().style.background = color;
|
||||
} catch {
|
||||
// ignore
|
||||
}
|
||||
}
|
||||
|
||||
function applyOceanLabelLanguage(map: maplibregl.Map, layers: string[], lang: OceanLabelLanguage) {
|
||||
const textField =
|
||||
lang === 'local'
|
||||
? ['get', 'name']
|
||||
: ['coalesce', ['get', `name:${lang}`], ['get', 'name']];
|
||||
for (const id of layers) {
|
||||
if (!map.getLayer(id)) continue;
|
||||
try {
|
||||
map.setLayoutProperty(id, 'text-field', textField);
|
||||
} catch {
|
||||
// ignore
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/* ── Hook ──────────────────────────────────────────────────────────── */
|
||||
export function useOceanMapSettings(
|
||||
mapRef: MutableRefObject<maplibregl.Map | null>,
|
||||
settings: OceanMapSettings | undefined,
|
||||
opts: { baseMap: BaseMapId; mapSyncEpoch: number },
|
||||
) {
|
||||
const settingsRef = useRef(settings);
|
||||
useEffect(() => {
|
||||
settingsRef.current = settings;
|
||||
});
|
||||
|
||||
const { baseMap, mapSyncEpoch } = opts;
|
||||
|
||||
useEffect(() => {
|
||||
// Ocean 전용 — enhanced 모드에서는 즉시 return
|
||||
if (baseMap !== 'ocean') return;
|
||||
|
||||
const map = mapRef.current;
|
||||
const s = settingsRef.current;
|
||||
if (!map || !s) return;
|
||||
|
||||
const stop = onMapStyleReady(map, () => {
|
||||
const oceanLayers = discoverOceanLayers(map);
|
||||
|
||||
applyOceanDepthColors(map, oceanLayers.depthFill, s.depthStops, s.depthOpacity);
|
||||
applyOceanContourStyle(map, oceanLayers.contourLine, s.contourVisible, s.contourColor, s.contourOpacity, s.contourWidth);
|
||||
applyOceanDepthLabels(map, oceanLayers.depthLabel, s.depthLabelsVisible, s.depthLabelColor, s.depthLabelSize);
|
||||
applyOceanHillshade(map, oceanLayers.hillshade, s.hillshadeVisible, s.hillshadeExaggeration, s.hillshadeColor);
|
||||
applyOceanLandformLabels(map, oceanLayers.landformLabel, s.landformLabelsVisible, s.landformLabelColor);
|
||||
applyOceanBackground(map, oceanLayers.background, s.backgroundColor);
|
||||
applyOceanLabelLanguage(map, oceanLayers.allSymbol, s.labelLanguage);
|
||||
|
||||
kickRepaint(map);
|
||||
});
|
||||
|
||||
return () => stop();
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, [settings, baseMap, mapSyncEpoch]);
|
||||
}
|
||||
6
apps/web/src/features/oceanMap/index.ts
Normal file
6
apps/web/src/features/oceanMap/index.ts
Normal file
@ -0,0 +1,6 @@
|
||||
export type { OceanMapSettings, OceanDepthStop, OceanLabelLanguage, OceanDepthLabelSize } from './model/types';
|
||||
export { DEFAULT_OCEAN_MAP_SETTINGS, OCEAN_PRESET_DEPTH_STOPS } from './model/types';
|
||||
export { resolveOceanStyle } from './lib/resolveOceanStyle';
|
||||
export { discoverOceanLayers } from './lib/oceanLayerIds';
|
||||
export { useOceanMapSettings } from './hooks/useOceanMapSettings';
|
||||
export { OceanMapSettingsPanel } from './ui/OceanMapSettingsPanel';
|
||||
77
apps/web/src/features/oceanMap/lib/oceanLayerIds.ts
Normal file
77
apps/web/src/features/oceanMap/lib/oceanLayerIds.ts
Normal file
@ -0,0 +1,77 @@
|
||||
import type maplibregl from 'maplibre-gl';
|
||||
|
||||
/**
|
||||
* Ocean 스타일 레이어를 source-layer 기반으로 동적 탐색.
|
||||
* MapTiler Ocean 스타일의 레이어 ID는 버전에 따라 변경될 수 있으므로,
|
||||
* ID 하드코딩 대신 source-layer + type 조합으로 필터링.
|
||||
*/
|
||||
|
||||
interface OceanLayerMap {
|
||||
depthFill: string[]; // source-layer: contour/depth, type: fill
|
||||
contourLine: string[]; // source-layer: contour_line/contour, type: line
|
||||
depthLabel: string[]; // source-layer: contour_line, type: symbol
|
||||
hillshade: string[]; // type: hillshade
|
||||
landformLabel: string[]; // source-layer: landform, type: symbol
|
||||
background: string[]; // type: background
|
||||
allSymbol: string[]; // type: symbol (라벨 언어 전환용)
|
||||
}
|
||||
|
||||
export function discoverOceanLayers(map: maplibregl.Map): OceanLayerMap {
|
||||
const result: OceanLayerMap = {
|
||||
depthFill: [],
|
||||
contourLine: [],
|
||||
depthLabel: [],
|
||||
hillshade: [],
|
||||
landformLabel: [],
|
||||
background: [],
|
||||
allSymbol: [],
|
||||
};
|
||||
|
||||
let layers: unknown[];
|
||||
try {
|
||||
const style = map.getStyle();
|
||||
layers = style && Array.isArray(style.layers) ? style.layers : [];
|
||||
} catch {
|
||||
return result;
|
||||
}
|
||||
|
||||
for (const layer of layers) {
|
||||
const spec = layer as Record<string, unknown>;
|
||||
const id = spec.id as string | undefined;
|
||||
const type = spec.type as string | undefined;
|
||||
const sourceLayer = spec['source-layer'] as string | undefined;
|
||||
if (!id || !type) continue;
|
||||
|
||||
if (type === 'background') {
|
||||
result.background.push(id);
|
||||
continue;
|
||||
}
|
||||
|
||||
if (type === 'hillshade') {
|
||||
result.hillshade.push(id);
|
||||
continue;
|
||||
}
|
||||
|
||||
if (type === 'symbol') {
|
||||
result.allSymbol.push(id);
|
||||
if (sourceLayer === 'landform' || sourceLayer === 'landform_point') {
|
||||
result.landformLabel.push(id);
|
||||
} else if (sourceLayer === 'contour_line' || sourceLayer === 'contour') {
|
||||
result.depthLabel.push(id);
|
||||
}
|
||||
continue;
|
||||
}
|
||||
|
||||
if (type === 'fill' && (sourceLayer === 'contour' || sourceLayer === 'depth')) {
|
||||
result.depthFill.push(id);
|
||||
continue;
|
||||
}
|
||||
|
||||
if (type === 'line' && (sourceLayer === 'contour_line' || sourceLayer === 'contour')) {
|
||||
result.contourLine.push(id);
|
||||
continue;
|
||||
}
|
||||
}
|
||||
|
||||
return result;
|
||||
}
|
||||
26
apps/web/src/features/oceanMap/lib/resolveOceanStyle.ts
Normal file
26
apps/web/src/features/oceanMap/lib/resolveOceanStyle.ts
Normal file
@ -0,0 +1,26 @@
|
||||
import type { LayerSpecification, StyleSpecification } from 'maplibre-gl';
|
||||
import { getMapTilerKey } from '../../../shared/lib/map/mapTilerKey';
|
||||
|
||||
export async function resolveOceanStyle(signal: AbortSignal): Promise<StyleSpecification> {
|
||||
const key = getMapTilerKey();
|
||||
if (!key) throw new Error('MapTiler key not found');
|
||||
|
||||
const url = `https://api.maptiler.com/maps/ocean/style.json?key=${encodeURIComponent(key)}`;
|
||||
const res = await fetch(url, { signal, headers: { accept: 'application/json' } });
|
||||
if (!res.ok) throw new Error(`Ocean style fetch failed: ${res.status}`);
|
||||
const style = (await res.json()) as StyleSpecification;
|
||||
|
||||
applyOceanKoreanLabels(style);
|
||||
return style;
|
||||
}
|
||||
|
||||
function applyOceanKoreanLabels(style: StyleSpecification) {
|
||||
if (!style.layers) return;
|
||||
const koTextField = ['coalesce', ['get', 'name:ko'], ['get', 'name']];
|
||||
for (const layer of style.layers as unknown as LayerSpecification[]) {
|
||||
if ((layer as { type?: string }).type !== 'symbol') continue;
|
||||
const layout = (layer as Record<string, unknown>).layout as Record<string, unknown> | undefined;
|
||||
if (!layout?.['text-field']) continue;
|
||||
layout['text-field'] = koTextField;
|
||||
}
|
||||
}
|
||||
90
apps/web/src/features/oceanMap/model/types.ts
Normal file
90
apps/web/src/features/oceanMap/model/types.ts
Normal file
@ -0,0 +1,90 @@
|
||||
export interface OceanDepthStop {
|
||||
depth: number; // 음수 (ex: -8000)
|
||||
color: string; // hex
|
||||
}
|
||||
|
||||
export type OceanLabelLanguage = 'ko' | 'en' | 'ja' | 'zh' | 'local';
|
||||
export type OceanDepthLabelSize = 'small' | 'medium' | 'large';
|
||||
|
||||
export interface OceanMapSettings {
|
||||
// ── 수심 색상 ──
|
||||
depthStops: OceanDepthStop[];
|
||||
depthOpacity: number; // 0~1
|
||||
|
||||
// ── 등심선 (contour lines) ──
|
||||
contourVisible: boolean;
|
||||
contourColor: string;
|
||||
contourOpacity: number; // 0~1
|
||||
contourWidth: number; // px, 0.5~3
|
||||
|
||||
// ── 수심 라벨 ──
|
||||
depthLabelsVisible: boolean;
|
||||
depthLabelColor: string;
|
||||
depthLabelSize: OceanDepthLabelSize;
|
||||
|
||||
// ── Hillshade ──
|
||||
hillshadeVisible: boolean;
|
||||
hillshadeExaggeration: number; // 0~1
|
||||
hillshadeColor: string;
|
||||
|
||||
// ── Landform 라벨 (해저 지형명) ──
|
||||
landformLabelsVisible: boolean;
|
||||
landformLabelColor: string;
|
||||
|
||||
// ── 배경 ──
|
||||
backgroundColor: string;
|
||||
|
||||
// ── 레이블 언어 ──
|
||||
labelLanguage: OceanLabelLanguage;
|
||||
}
|
||||
|
||||
/**
|
||||
* Ocean 커스텀 수심 색상 프리셋 (12구간).
|
||||
* 사용자가 "커스텀" 버튼을 누르면 이 값으로 초기화된다.
|
||||
* Ocean 스타일에 어울리는 심해 → 천해 블루 그라데이션.
|
||||
*/
|
||||
export const OCEAN_PRESET_DEPTH_STOPS: OceanDepthStop[] = [
|
||||
{ depth: -11000, color: '#0a0e2a' },
|
||||
{ depth: -8000, color: '#0c1836' },
|
||||
{ depth: -6000, color: '#0e2444' },
|
||||
{ depth: -4000, color: '#103252' },
|
||||
{ depth: -2000, color: '#134060' },
|
||||
{ depth: -1000, color: '#175070' },
|
||||
{ depth: -200, color: '#1c6480' },
|
||||
{ depth: -100, color: '#217890' },
|
||||
{ depth: -50, color: '#288da0' },
|
||||
{ depth: -20, color: '#30a2b0' },
|
||||
{ depth: -10, color: '#3ab5be' },
|
||||
{ depth: 0, color: '#48c8cc' },
|
||||
];
|
||||
|
||||
/**
|
||||
* Ocean 스타일 기본 설정.
|
||||
* depthStops가 비어있으면 Ocean 스타일의 네이티브 수심 색상을 유지한다.
|
||||
* 사용자가 커스텀하면 depthStops에 값이 채워져 적용된다.
|
||||
*/
|
||||
export const DEFAULT_OCEAN_MAP_SETTINGS: OceanMapSettings = {
|
||||
// 빈 배열 = Ocean 스타일 네이티브 색상 사용 (커스텀 안 함)
|
||||
depthStops: [],
|
||||
depthOpacity: 1,
|
||||
|
||||
contourVisible: true,
|
||||
contourColor: '#4a90c2',
|
||||
contourOpacity: 0.15,
|
||||
contourWidth: 0.6,
|
||||
|
||||
depthLabelsVisible: true,
|
||||
depthLabelColor: '#a0c4e8',
|
||||
depthLabelSize: 'medium',
|
||||
|
||||
hillshadeVisible: true,
|
||||
hillshadeExaggeration: 0.5,
|
||||
hillshadeColor: '#0a1628',
|
||||
|
||||
landformLabelsVisible: true,
|
||||
landformLabelColor: '#7ea8c8',
|
||||
|
||||
backgroundColor: '#0b1a2e',
|
||||
|
||||
labelLanguage: 'ko',
|
||||
};
|
||||
371
apps/web/src/features/oceanMap/ui/OceanMapSettingsPanel.tsx
Normal file
371
apps/web/src/features/oceanMap/ui/OceanMapSettingsPanel.tsx
Normal file
@ -0,0 +1,371 @@
|
||||
import { useState } from 'react';
|
||||
import type { OceanMapSettings, OceanLabelLanguage, OceanDepthLabelSize, OceanDepthStop } from '../model/types';
|
||||
import { DEFAULT_OCEAN_MAP_SETTINGS, OCEAN_PRESET_DEPTH_STOPS } from '../model/types';
|
||||
|
||||
interface OceanMapSettingsPanelProps {
|
||||
value: OceanMapSettings;
|
||||
onChange: (next: OceanMapSettings) => void;
|
||||
}
|
||||
|
||||
const LANGUAGES: { value: OceanLabelLanguage; label: string }[] = [
|
||||
{ value: 'ko', label: '한국어' },
|
||||
{ value: 'en', label: 'English' },
|
||||
{ value: 'ja', label: '日本語' },
|
||||
{ value: 'zh', label: '中文' },
|
||||
{ value: 'local', label: '현지어' },
|
||||
];
|
||||
|
||||
const FONT_SIZES: { value: OceanDepthLabelSize; label: string }[] = [
|
||||
{ value: 'small', label: '소' },
|
||||
{ value: 'medium', label: '중' },
|
||||
{ value: 'large', label: '대' },
|
||||
];
|
||||
|
||||
// 설정 패널에 표시할 주요 수심 구간 (전체 12개 중 6개만 UI 표시)
|
||||
const DISPLAY_DEPTH_INDICES = [0, 2, 4, 6, 8, 11]; // -11000, -6000, -2000, -200, -50, 0
|
||||
|
||||
function depthLabel(depth: number): string {
|
||||
return `${Math.abs(depth).toLocaleString()}m`;
|
||||
}
|
||||
|
||||
function hexToRgb(hex: string): [number, number, number] {
|
||||
return [
|
||||
parseInt(hex.slice(1, 3), 16),
|
||||
parseInt(hex.slice(3, 5), 16),
|
||||
parseInt(hex.slice(5, 7), 16),
|
||||
];
|
||||
}
|
||||
|
||||
function rgbToHex(r: number, g: number, b: number): string {
|
||||
return `#${[r, g, b].map((c) => Math.round(Math.max(0, Math.min(255, c))).toString(16).padStart(2, '0')).join('')}`;
|
||||
}
|
||||
|
||||
function interpolateGradient(stops: OceanDepthStop[]): OceanDepthStop[] {
|
||||
if (stops.length < 2) return stops;
|
||||
const sorted = [...stops].sort((a, b) => a.depth - b.depth);
|
||||
const first = sorted[0];
|
||||
const last = sorted[sorted.length - 1];
|
||||
const [r1, g1, b1] = hexToRgb(first.color);
|
||||
const [r2, g2, b2] = hexToRgb(last.color);
|
||||
return sorted.map((stop, i) => {
|
||||
if (i === 0 || i === sorted.length - 1) return stop;
|
||||
const t = i / (sorted.length - 1);
|
||||
return {
|
||||
depth: stop.depth,
|
||||
color: rgbToHex(r1 + (r2 - r1) * t, g1 + (g2 - g1) * t, b1 + (b2 - b1) * t),
|
||||
};
|
||||
});
|
||||
}
|
||||
|
||||
export function OceanMapSettingsPanel({ value, onChange }: OceanMapSettingsPanelProps) {
|
||||
const [open, setOpen] = useState(false);
|
||||
const [autoGradient, setAutoGradient] = useState(false);
|
||||
|
||||
const update = <K extends keyof OceanMapSettings>(key: K, val: OceanMapSettings[K]) => {
|
||||
onChange({ ...value, [key]: val });
|
||||
};
|
||||
|
||||
const updateDepthStop = (index: number, color: string) => {
|
||||
const next = value.depthStops.map((s, i) => (i === index ? { ...s, color } : s));
|
||||
if (autoGradient && (index === 0 || index === next.length - 1)) {
|
||||
update('depthStops', interpolateGradient(next));
|
||||
} else {
|
||||
update('depthStops', next);
|
||||
}
|
||||
};
|
||||
|
||||
const toggleAutoGradient = () => {
|
||||
const next = !autoGradient;
|
||||
setAutoGradient(next);
|
||||
if (next) {
|
||||
update('depthStops', interpolateGradient(value.depthStops));
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<>
|
||||
<button
|
||||
className={`map-settings-gear${open ? ' open' : ''}`}
|
||||
onClick={() => setOpen((p) => !p)}
|
||||
title="Ocean 지도 설정"
|
||||
type="button"
|
||||
>
|
||||
⚙
|
||||
</button>
|
||||
|
||||
{open && (
|
||||
<div className="map-settings-panel">
|
||||
<div className="ms-title">Ocean 지도 설정</div>
|
||||
|
||||
{/* ── Language ──────────────────────────────── */}
|
||||
<div className="ms-section">
|
||||
<div className="ms-label">레이블 언어</div>
|
||||
<select
|
||||
value={value.labelLanguage}
|
||||
onChange={(e) => update('labelLanguage', e.target.value as OceanLabelLanguage)}
|
||||
>
|
||||
{LANGUAGES.map((l) => (
|
||||
<option key={l.value} value={l.value}>{l.label}</option>
|
||||
))}
|
||||
</select>
|
||||
</div>
|
||||
|
||||
{/* ── Depth gradient ────────────────────────── */}
|
||||
<div className="ms-section">
|
||||
<div className="ms-label" style={{ display: 'flex', alignItems: 'center', justifyContent: 'space-between' }}>
|
||||
수심 구간 색상
|
||||
<span style={{ display: 'flex', gap: 4 }}>
|
||||
{value.depthStops.length > 0 && (
|
||||
<span
|
||||
className={`cursor-pointer rounded border px-1.5 py-px text-[8px] transition-all duration-150 select-none ${autoGradient ? 'border-wing-accent bg-wing-accent text-white' : 'border-wing-border bg-wing-card text-wing-muted'}`}
|
||||
onClick={toggleAutoGradient}
|
||||
title="최소/최대 색상 기준으로 중간 구간을 자동 보간합니다"
|
||||
>
|
||||
자동채우기
|
||||
</span>
|
||||
)}
|
||||
<span
|
||||
className={`cursor-pointer rounded border px-1.5 py-px text-[8px] transition-all duration-150 select-none ${value.depthStops.length > 0 ? 'border-wing-accent bg-wing-accent text-white' : 'border-wing-border bg-wing-card text-wing-muted'}`}
|
||||
onClick={() => {
|
||||
if (value.depthStops.length > 0) {
|
||||
update('depthStops', []);
|
||||
setAutoGradient(false);
|
||||
} else {
|
||||
update('depthStops', OCEAN_PRESET_DEPTH_STOPS);
|
||||
}
|
||||
}}
|
||||
title={value.depthStops.length > 0 ? 'Ocean 스타일 네이티브 색상으로 복원' : '수심 구간별 색상을 직접 지정합니다'}
|
||||
>
|
||||
{value.depthStops.length > 0 ? '기본값' : '커스텀'}
|
||||
</span>
|
||||
</span>
|
||||
</div>
|
||||
{value.depthStops.length === 0 ? (
|
||||
<div className="ms-row" style={{ fontSize: 9, color: 'var(--muted)' }}>
|
||||
기본 스타일 사용 중
|
||||
</div>
|
||||
) : (
|
||||
DISPLAY_DEPTH_INDICES.map((idx) => {
|
||||
const stop = value.depthStops[idx];
|
||||
if (!stop) return null;
|
||||
const isEdge = idx === 0 || idx === value.depthStops.length - 1;
|
||||
const dimmed = autoGradient && !isEdge;
|
||||
return (
|
||||
<div className="ms-row" key={stop.depth} style={dimmed ? { opacity: 0.5 } : undefined}>
|
||||
<span className="ms-depth-label">{depthLabel(stop.depth)}</span>
|
||||
<input
|
||||
type="color"
|
||||
className="ms-color-input"
|
||||
value={stop.color}
|
||||
onChange={(e) => updateDepthStop(idx, e.target.value)}
|
||||
disabled={dimmed}
|
||||
/>
|
||||
<span className="ms-hex">{stop.color}</span>
|
||||
</div>
|
||||
);
|
||||
})
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* ── Depth opacity ─────────────────────────── */}
|
||||
<div className="ms-section">
|
||||
<div className="ms-label">수심 투명도</div>
|
||||
<div className="ms-row">
|
||||
<input
|
||||
type="range"
|
||||
min={0}
|
||||
max={1}
|
||||
step={0.05}
|
||||
value={value.depthOpacity}
|
||||
onChange={(e) => update('depthOpacity', Number(e.target.value))}
|
||||
className="ms-slider"
|
||||
/>
|
||||
<span className="ms-hex">{value.depthOpacity.toFixed(2)}</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* ── Contour lines ─────────────────────────── */}
|
||||
<div className="ms-section">
|
||||
<div className="ms-label" style={{ display: 'flex', alignItems: 'center', justifyContent: 'space-between' }}>
|
||||
등심선
|
||||
<span
|
||||
className={`cursor-pointer rounded border px-1.5 py-px text-[8px] transition-all duration-150 select-none ${value.contourVisible ? 'border-wing-accent bg-wing-accent text-white' : 'border-wing-border bg-wing-card text-wing-muted'}`}
|
||||
onClick={() => update('contourVisible', !value.contourVisible)}
|
||||
>
|
||||
{value.contourVisible ? 'ON' : 'OFF'}
|
||||
</span>
|
||||
</div>
|
||||
{value.contourVisible && (
|
||||
<>
|
||||
<div className="ms-row">
|
||||
<span className="ms-depth-label">색상</span>
|
||||
<input
|
||||
type="color"
|
||||
className="ms-color-input"
|
||||
value={value.contourColor}
|
||||
onChange={(e) => update('contourColor', e.target.value)}
|
||||
/>
|
||||
</div>
|
||||
<div className="ms-row">
|
||||
<span className="ms-depth-label">투명도</span>
|
||||
<input
|
||||
type="range"
|
||||
min={0}
|
||||
max={1}
|
||||
step={0.05}
|
||||
value={value.contourOpacity}
|
||||
onChange={(e) => update('contourOpacity', Number(e.target.value))}
|
||||
className="ms-slider"
|
||||
/>
|
||||
<span className="ms-hex">{value.contourOpacity.toFixed(2)}</span>
|
||||
</div>
|
||||
<div className="ms-row">
|
||||
<span className="ms-depth-label">굵기</span>
|
||||
<input
|
||||
type="range"
|
||||
min={0.5}
|
||||
max={3}
|
||||
step={0.1}
|
||||
value={value.contourWidth}
|
||||
onChange={(e) => update('contourWidth', Number(e.target.value))}
|
||||
className="ms-slider"
|
||||
/>
|
||||
<span className="ms-hex">{value.contourWidth.toFixed(1)}</span>
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* ── Depth labels ──────────────────────────── */}
|
||||
<div className="ms-section">
|
||||
<div className="ms-label" style={{ display: 'flex', alignItems: 'center', justifyContent: 'space-between' }}>
|
||||
수심 라벨
|
||||
<span
|
||||
className={`cursor-pointer rounded border px-1.5 py-px text-[8px] transition-all duration-150 select-none ${value.depthLabelsVisible ? 'border-wing-accent bg-wing-accent text-white' : 'border-wing-border bg-wing-card text-wing-muted'}`}
|
||||
onClick={() => update('depthLabelsVisible', !value.depthLabelsVisible)}
|
||||
>
|
||||
{value.depthLabelsVisible ? 'ON' : 'OFF'}
|
||||
</span>
|
||||
</div>
|
||||
{value.depthLabelsVisible && (
|
||||
<>
|
||||
<div className="ms-row">
|
||||
<span className="ms-depth-label">색상</span>
|
||||
<input
|
||||
type="color"
|
||||
className="ms-color-input"
|
||||
value={value.depthLabelColor}
|
||||
onChange={(e) => update('depthLabelColor', e.target.value)}
|
||||
/>
|
||||
</div>
|
||||
<div className="ms-row">
|
||||
<span className="ms-depth-label">크기</span>
|
||||
<div className="flex flex-wrap gap-0.75">
|
||||
{FONT_SIZES.map((fs) => (
|
||||
<div
|
||||
key={fs.value}
|
||||
className={`cursor-pointer rounded border px-1.5 py-0.5 text-[8px] transition-all duration-150 select-none ${value.depthLabelSize === fs.value ? 'border-wing-accent bg-wing-accent text-white' : 'border-wing-border bg-wing-card text-wing-muted'}`}
|
||||
onClick={() => update('depthLabelSize', fs.value)}
|
||||
>
|
||||
{fs.label}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* ── Hillshade ─────────────────────────────── */}
|
||||
<div className="ms-section">
|
||||
<div className="ms-label" style={{ display: 'flex', alignItems: 'center', justifyContent: 'space-between' }}>
|
||||
Hillshade
|
||||
<span
|
||||
className={`cursor-pointer rounded border px-1.5 py-px text-[8px] transition-all duration-150 select-none ${value.hillshadeVisible ? 'border-wing-accent bg-wing-accent text-white' : 'border-wing-border bg-wing-card text-wing-muted'}`}
|
||||
onClick={() => update('hillshadeVisible', !value.hillshadeVisible)}
|
||||
>
|
||||
{value.hillshadeVisible ? 'ON' : 'OFF'}
|
||||
</span>
|
||||
</div>
|
||||
{value.hillshadeVisible && (
|
||||
<>
|
||||
<div className="ms-row">
|
||||
<span className="ms-depth-label">강도</span>
|
||||
<input
|
||||
type="range"
|
||||
min={0}
|
||||
max={1}
|
||||
step={0.05}
|
||||
value={value.hillshadeExaggeration}
|
||||
onChange={(e) => update('hillshadeExaggeration', Number(e.target.value))}
|
||||
className="ms-slider"
|
||||
/>
|
||||
<span className="ms-hex">{value.hillshadeExaggeration.toFixed(2)}</span>
|
||||
</div>
|
||||
<div className="ms-row">
|
||||
<span className="ms-depth-label">그림자 색</span>
|
||||
<input
|
||||
type="color"
|
||||
className="ms-color-input"
|
||||
value={value.hillshadeColor}
|
||||
onChange={(e) => update('hillshadeColor', e.target.value)}
|
||||
/>
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* ── Landform labels ───────────────────────── */}
|
||||
<div className="ms-section">
|
||||
<div className="ms-label" style={{ display: 'flex', alignItems: 'center', justifyContent: 'space-between' }}>
|
||||
해저 지형명
|
||||
<span
|
||||
className={`cursor-pointer rounded border px-1.5 py-px text-[8px] transition-all duration-150 select-none ${value.landformLabelsVisible ? 'border-wing-accent bg-wing-accent text-white' : 'border-wing-border bg-wing-card text-wing-muted'}`}
|
||||
onClick={() => update('landformLabelsVisible', !value.landformLabelsVisible)}
|
||||
>
|
||||
{value.landformLabelsVisible ? 'ON' : 'OFF'}
|
||||
</span>
|
||||
</div>
|
||||
{value.landformLabelsVisible && (
|
||||
<div className="ms-row">
|
||||
<span className="ms-depth-label">색상</span>
|
||||
<input
|
||||
type="color"
|
||||
className="ms-color-input"
|
||||
value={value.landformLabelColor}
|
||||
onChange={(e) => update('landformLabelColor', e.target.value)}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* ── Background ────────────────────────────── */}
|
||||
<div className="ms-section">
|
||||
<div className="ms-label">배경색</div>
|
||||
<div className="ms-row">
|
||||
<input
|
||||
type="color"
|
||||
className="ms-color-input"
|
||||
value={value.backgroundColor}
|
||||
onChange={(e) => update('backgroundColor', e.target.value)}
|
||||
/>
|
||||
<span className="ms-hex">{value.backgroundColor}</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* ── Reset ─────────────────────────────────── */}
|
||||
<button
|
||||
className="ms-reset"
|
||||
type="button"
|
||||
onClick={() => {
|
||||
onChange(DEFAULT_OCEAN_MAP_SETTINGS);
|
||||
setAutoGradient(false);
|
||||
}}
|
||||
>
|
||||
초기화
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
</>
|
||||
);
|
||||
}
|
||||
@ -28,6 +28,8 @@ import { useWeatherOverlay } from "../../features/weatherOverlay/useWeatherOverl
|
||||
import { WeatherPanel } from "../../widgets/weatherPanel/WeatherPanel";
|
||||
import { WeatherOverlayPanel } from "../../widgets/weatherOverlay/WeatherOverlayPanel";
|
||||
import { MapSettingsPanel } from "../../features/mapSettings/MapSettingsPanel";
|
||||
import { OceanMapSettingsPanel } from "../../features/oceanMap/ui/OceanMapSettingsPanel";
|
||||
import { useAnnouncementPopup, AnnouncementModal } from "../../features/announcement";
|
||||
import {
|
||||
buildLegacyHitMap,
|
||||
computeCountsByType,
|
||||
@ -61,6 +63,9 @@ export function DashboardPage() {
|
||||
const uid = user?.id ?? null;
|
||||
const isDevMode = user?.name?.includes('(DEV)') ?? false;
|
||||
|
||||
// ── Announcement popup ──
|
||||
const { hasUnread, unreadAnnouncements, acknowledge } = useAnnouncementPopup(uid);
|
||||
|
||||
// ── Data fetching ──
|
||||
const { data: zones, error: zonesError } = useZones();
|
||||
const { data: legacyData, error: legacyError } = useLegacyVessels();
|
||||
@ -412,6 +417,7 @@ export function DashboardPage() {
|
||||
alarmMmsiMap={alarmMmsiMap}
|
||||
onClickShipPhoto={handleOpenImageModal}
|
||||
freeCamera={state.freeCamera}
|
||||
oceanMapSettings={state.oceanMapSettings}
|
||||
/>
|
||||
<GlobalTrackReplayPanel />
|
||||
<WeatherPanel
|
||||
@ -421,14 +427,21 @@ export function DashboardPage() {
|
||||
onRefresh={weather.refresh}
|
||||
/>
|
||||
<WeatherOverlayPanel {...weatherOverlay} />
|
||||
<MapSettingsPanel value={mapStyleSettings} onChange={setMapStyleSettings} />
|
||||
<DepthLegend depthStops={mapStyleSettings.depthStops} />
|
||||
{baseMap === 'ocean' ? (
|
||||
<OceanMapSettingsPanel value={state.oceanMapSettings} onChange={state.setOceanMapSettings} />
|
||||
) : (
|
||||
<MapSettingsPanel value={mapStyleSettings} onChange={setMapStyleSettings} />
|
||||
)}
|
||||
{baseMap !== 'ocean' && <DepthLegend depthStops={mapStyleSettings.depthStops} />}
|
||||
<MapLegend />
|
||||
{selectedLegacyVessel ? (
|
||||
<VesselInfoPanel vessel={selectedLegacyVessel} allVessels={legacyVesselsAll} onClose={() => setSelectedMmsi(null)} onSelectMmsi={setSelectedMmsi} imo={selectedTarget && selectedTarget.imo > 0 ? selectedTarget.imo : undefined} shipImagePath={selectedTarget?.shipImagePath} shipImageCount={selectedTarget?.shipImageCount} onOpenImageModal={handlePanelOpenImageModal} />
|
||||
) : selectedTarget ? (
|
||||
<AisInfoPanel target={selectedTarget} legacy={selectedLegacyInfo} onClose={() => setSelectedMmsi(null)} onOpenImageModal={handlePanelOpenImageModal} />
|
||||
) : null}
|
||||
{hasUnread && (
|
||||
<AnnouncementModal announcements={unreadAnnouncements} onConfirm={acknowledge} />
|
||||
)}
|
||||
{imageModal && (
|
||||
<ShipImageModal
|
||||
images={imageModal.images}
|
||||
|
||||
@ -81,6 +81,7 @@ export function DashboardSidebar({
|
||||
overlays, setOverlays,
|
||||
projection, setProjection, isProjectionToggleDisabled,
|
||||
freeCamera, toggleFreeCamera,
|
||||
baseMap, setBaseMap,
|
||||
selectedMmsi, setSelectedMmsi,
|
||||
fleetRelationSortMode, setFleetRelationSortMode,
|
||||
hoveredFleetOwnerKey, hoveredFleetMmsiSet,
|
||||
@ -168,6 +169,14 @@ export function DashboardSidebar({
|
||||
>
|
||||
자유 시점
|
||||
</ToggleButton>
|
||||
<ToggleButton
|
||||
on={baseMap === 'ocean'}
|
||||
onClick={() => setBaseMap(baseMap === 'ocean' ? 'enhanced' : 'ocean')}
|
||||
title="Ocean 전용 지도 (해양 정보 극대화)"
|
||||
className="px-2 py-0.5 text-[9px]"
|
||||
>
|
||||
Ocean
|
||||
</ToggleButton>
|
||||
<ToggleButton
|
||||
on={projection === 'globe'}
|
||||
onClick={isProjectionToggleDisabled ? undefined : () => setProjection((p) => (p === 'globe' ? 'mercator' : 'globe'))}
|
||||
|
||||
@ -8,6 +8,8 @@ import type { BaseMapId, Map3DSettings, MapProjectionId } from '../../widgets/ma
|
||||
import type { MapViewState } from '../../widgets/map3d/types';
|
||||
import { DEFAULT_MAP_STYLE_SETTINGS } from '../../features/mapSettings/types';
|
||||
import type { MapStyleSettings } from '../../features/mapSettings/types';
|
||||
import { DEFAULT_OCEAN_MAP_SETTINGS } from '../../features/oceanMap/model/types';
|
||||
import type { OceanMapSettings } from '../../features/oceanMap/model/types';
|
||||
import { fmtDateTimeFull } from '../../shared/lib/datetime';
|
||||
|
||||
export type Bbox = [number, number, number, number];
|
||||
@ -40,9 +42,7 @@ export function useDashboardState(uid: number | null) {
|
||||
const [showOthers, setShowOthers] = usePersistedState(uid, 'showOthers', true);
|
||||
|
||||
// ── Map settings (persisted) ──
|
||||
// 레거시 베이스맵 비활성 — 향후 위성/라이트 등 추가 시 재활용
|
||||
// eslint-disable-next-line @typescript-eslint/no-unused-vars
|
||||
const [baseMap, _setBaseMap] = useState<BaseMapId>('enhanced');
|
||||
const [baseMap, setBaseMap] = usePersistedState<BaseMapId>(uid, 'baseMap', 'ocean');
|
||||
const [projection, setProjection] = useState<MapProjectionId>('mercator');
|
||||
const [mapStyleSettings, setMapStyleSettings] = usePersistedState<MapStyleSettings>(uid, 'mapStyleSettings', DEFAULT_MAP_STYLE_SETTINGS);
|
||||
const [overlays, setOverlays] = usePersistedState<MapToggleState>(uid, 'overlays', {
|
||||
@ -53,6 +53,7 @@ export function useDashboardState(uid: number | null) {
|
||||
showShips: true, showDensity: false, showSeamark: false,
|
||||
});
|
||||
const [mapView, setMapView] = usePersistedState<MapViewState | null>(uid, 'mapView', null);
|
||||
const [oceanMapSettings, setOceanMapSettings] = usePersistedState<OceanMapSettings>(uid, 'oceanMapSettings', DEFAULT_OCEAN_MAP_SETTINGS);
|
||||
|
||||
// ── 자유 시점 (모드별 독립) ──
|
||||
const [freeCameraMercator, setFreeCameraMercator] = usePersistedState(uid, 'freeCameraMercator', true);
|
||||
@ -138,10 +139,10 @@ export function useDashboardState(uid: number | null) {
|
||||
hoveredPairMmsiSet, setHoveredPairMmsiSet,
|
||||
hoveredFleetOwnerKey, setHoveredFleetOwnerKey,
|
||||
typeEnabled, setTypeEnabled, showTargets, setShowTargets, showOthers, setShowOthers,
|
||||
baseMap, projection, setProjection,
|
||||
baseMap, setBaseMap, projection, setProjection,
|
||||
mapStyleSettings, setMapStyleSettings,
|
||||
overlays, setOverlays, settings, setSettings,
|
||||
mapView, setMapView, freeCamera, toggleFreeCamera,
|
||||
mapView, setMapView, freeCamera, toggleFreeCamera, oceanMapSettings, setOceanMapSettings,
|
||||
fleetRelationSortMode, setFleetRelationSortMode,
|
||||
alarmKindEnabled, setAlarmKindEnabled,
|
||||
fleetFocus, setFleetFocus,
|
||||
|
||||
@ -28,6 +28,7 @@ import { useDeckLayers } from './hooks/useDeckLayers';
|
||||
import { useSubcablesLayer } from './hooks/useSubcablesLayer';
|
||||
import { useTrackReplayLayer } from './hooks/useTrackReplayLayer';
|
||||
import { useMapStyleSettings } from './hooks/useMapStyleSettings';
|
||||
import { useOceanMapSettings } from '../../features/oceanMap/hooks/useOceanMapSettings';
|
||||
import { VesselContextMenu } from './components/VesselContextMenu';
|
||||
import { useLiveShipAdapter } from '../../features/liveRenderer/hooks/useLiveShipAdapter';
|
||||
import { useLiveShipBatchRender } from '../../features/liveRenderer/hooks/useLiveShipBatchRender';
|
||||
@ -84,6 +85,7 @@ export function Map3D({
|
||||
alarmMmsiMap,
|
||||
onClickShipPhoto,
|
||||
freeCamera = true,
|
||||
oceanMapSettings,
|
||||
}: Props) {
|
||||
// ── Shared refs ──────────────────────────────────────────────────────
|
||||
const containerRef = useRef<HTMLDivElement | null>(null);
|
||||
@ -575,6 +577,7 @@ export function Map3D({
|
||||
);
|
||||
|
||||
useMapStyleSettings(mapRef, mapStyleSettings, { baseMap, mapSyncEpoch });
|
||||
useOceanMapSettings(mapRef, oceanMapSettings, { baseMap, mapSyncEpoch });
|
||||
|
||||
useZonesLayer(
|
||||
mapRef, projectionBusyRef, reorderGlobeFeatureLayers,
|
||||
|
||||
@ -579,9 +579,9 @@ export function useGlobeShipLayers(
|
||||
['==', ['feature-state', 'highlighted'], 1], 'rgba(245,158,11,0.95)',
|
||||
'rgba(226,232,240,0.92)',
|
||||
] as never,
|
||||
'text-halo-color': 'rgba(2,6,23,0.85)',
|
||||
'text-halo-width': 1.2,
|
||||
'text-halo-blur': 0.8,
|
||||
'text-halo-color': 'rgba(0,0,0,0.9)',
|
||||
'text-halo-width': 0.8,
|
||||
'text-halo-blur': 0.2,
|
||||
},
|
||||
} as unknown as LayerSpecification,
|
||||
undefined,
|
||||
|
||||
@ -163,6 +163,8 @@ export function useMapStyleSettings(
|
||||
const map = mapRef.current;
|
||||
const s = settingsRef.current;
|
||||
if (!map || !s) return;
|
||||
// Ocean 모드는 useOceanMapSettings에서 별도 처리
|
||||
if (baseMap === 'ocean') return;
|
||||
|
||||
const stop = onMapStyleReady(map, () => {
|
||||
applyLabelLanguage(map, s.labelLanguage);
|
||||
|
||||
@ -390,6 +390,10 @@ export async function resolveInitialMapStyle(signal: AbortSignal): Promise<strin
|
||||
}
|
||||
|
||||
export async function resolveMapStyle(baseMap: BaseMapId, signal: AbortSignal): Promise<string | StyleSpecification> {
|
||||
if (baseMap === 'ocean') {
|
||||
const { resolveOceanStyle } = await import('../../../features/oceanMap/lib/resolveOceanStyle');
|
||||
return resolveOceanStyle(signal);
|
||||
}
|
||||
// 레거시 베이스맵 비활성 — 향후 위성/라이트 테마 등 추가 시 재활용
|
||||
// if (baseMap === 'legacy') return '/map/styles/carto-dark.json';
|
||||
void baseMap;
|
||||
|
||||
@ -489,8 +489,8 @@ export function buildMercatorDeckLayers(ctx: MercatorDeckLayerContext): unknown[
|
||||
characterSet: 'auto',
|
||||
getPixelOffset: [0, 16],
|
||||
getTextAnchor: 'middle',
|
||||
outlineWidth: 2,
|
||||
outlineColor: [2, 6, 23, 217],
|
||||
outlineWidth: 1,
|
||||
outlineColor: [0, 0, 0, 230],
|
||||
}),
|
||||
);
|
||||
}
|
||||
|
||||
@ -6,6 +6,7 @@ import type { ZonesGeoJson } from '../../entities/zone/api/useZones';
|
||||
import type { MapToggleState } from '../../features/mapToggles/MapToggles';
|
||||
import type { FcLink, FleetCircle, LegacyAlarmKind, PairLink } from '../../features/legacyDashboard/model/types';
|
||||
import type { MapStyleSettings } from '../../features/mapSettings/types';
|
||||
import type { OceanMapSettings } from '../../features/oceanMap/model/types';
|
||||
|
||||
export type Map3DSettings = {
|
||||
showSeamark: boolean;
|
||||
@ -13,7 +14,7 @@ export type Map3DSettings = {
|
||||
showDensity: boolean;
|
||||
};
|
||||
|
||||
export type BaseMapId = 'enhanced' | 'legacy';
|
||||
export type BaseMapId = 'enhanced' | 'ocean' | 'legacy';
|
||||
export type MapProjectionId = 'mercator' | 'globe';
|
||||
|
||||
export interface MapViewState {
|
||||
@ -75,6 +76,8 @@ export interface Map3DProps {
|
||||
onClickShipPhoto?: (mmsi: number) => void;
|
||||
/** 자유 시점 모드 (회전/틸트 허용) */
|
||||
freeCamera?: boolean;
|
||||
/** Ocean 지도 전용 설정 */
|
||||
oceanMapSettings?: OceanMapSettings;
|
||||
}
|
||||
|
||||
export type DashSeg = {
|
||||
|
||||
불러오는 중...
Reference in New Issue
Block a user