develop #44

병합
htlee develop 에서 main 로 5 commits 를 머지했습니다 2026-02-21 00:13:38 +09:00
25개의 변경된 파일1448개의 추가작업 그리고 13개의 파일을 삭제

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

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

파일 보기

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

파일 보기

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

파일 보기

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

파일 보기

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

파일 보기

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

파일 보기

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

파일 보기

@ -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[];
}

파일 보기

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

파일 보기

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

파일 보기

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

파일 보기

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

파일 보기

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

파일 보기

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

파일 보기

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