diff --git a/.claude/skills/mr/SKILL.md b/.claude/skills/mr/SKILL.md new file mode 100644 index 0000000..42bb407 --- /dev/null +++ b/.claude/skills/mr/SKILL.md @@ -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 생성만, 세부 옵션 지원 (상세 제어) diff --git a/.claude/skills/push/SKILL.md b/.claude/skills/push/SKILL.md new file mode 100644 index 0000000..a955c8d --- /dev/null +++ b/.claude/skills/push/SKILL.md @@ -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(-) +``` diff --git a/.claude/skills/release/SKILL.md b/.claude/skills/release/SKILL.md new file mode 100644 index 0000000..fe289f9 --- /dev/null +++ b/.claude/skills/release/SKILL.md @@ -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 접근 토큰 diff --git a/apps/web/src/app/styles.css b/apps/web/src/app/styles.css index 063b98e..e218375 100644 --- a/apps/web/src/app/styles.css +++ b/apps/web/src/app/styles.css @@ -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"; diff --git a/apps/web/src/app/styles/components/announcement.css b/apps/web/src/app/styles/components/announcement.css new file mode 100644 index 0000000..aae03e9 --- /dev/null +++ b/apps/web/src/app/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); +} diff --git a/apps/web/src/features/announcement/data/announcements.ts b/apps/web/src/features/announcement/data/announcements.ts new file mode 100644 index 0000000..5582f93 --- /dev/null +++ b/apps/web/src/features/announcement/data/announcements.ts @@ -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; diff --git a/apps/web/src/features/announcement/hooks/useAnnouncementPopup.ts b/apps/web/src/features/announcement/hooks/useAnnouncementPopup.ts new file mode 100644 index 0000000..34676c8 --- /dev/null +++ b/apps/web/src/features/announcement/hooks/useAnnouncementPopup.ts @@ -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( + 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 }; +} diff --git a/apps/web/src/features/announcement/index.ts b/apps/web/src/features/announcement/index.ts new file mode 100644 index 0000000..4ba6d2b --- /dev/null +++ b/apps/web/src/features/announcement/index.ts @@ -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'; diff --git a/apps/web/src/features/announcement/model/types.ts b/apps/web/src/features/announcement/model/types.ts new file mode 100644 index 0000000..4a1c774 --- /dev/null +++ b/apps/web/src/features/announcement/model/types.ts @@ -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[]; +} diff --git a/apps/web/src/features/announcement/ui/AnnouncementModal.tsx b/apps/web/src/features/announcement/ui/AnnouncementModal.tsx new file mode 100644 index 0000000..14ad048 --- /dev/null +++ b/apps/web/src/features/announcement/ui/AnnouncementModal.tsx @@ -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 ( +
+
e.stopPropagation()}> +
+ 업데이트 안내 + +
+ +
+ {announcements.map((announcement) => ( +
+
+ {announcement.title} + {announcement.date} +
+
    + {announcement.items.map((item, idx) => ( +
  • + {item.icon} +
    +
    {item.title}
    + {item.description && ( +
    {item.description}
    + )} +
    +
  • + ))} +
+
+ ))} +
+ +
+ +
+
+
+ ); +} diff --git a/apps/web/src/features/oceanMap/hooks/useOceanMapSettings.ts b/apps/web/src/features/oceanMap/hooks/useOceanMapSettings.ts new file mode 100644 index 0000000..6197dd2 --- /dev/null +++ b/apps/web/src/features/oceanMap/hooks/useOceanMapSettings.ts @@ -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 = { + 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, + 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]); +} diff --git a/apps/web/src/features/oceanMap/index.ts b/apps/web/src/features/oceanMap/index.ts new file mode 100644 index 0000000..28fd507 --- /dev/null +++ b/apps/web/src/features/oceanMap/index.ts @@ -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'; diff --git a/apps/web/src/features/oceanMap/lib/oceanLayerIds.ts b/apps/web/src/features/oceanMap/lib/oceanLayerIds.ts new file mode 100644 index 0000000..0e7a3de --- /dev/null +++ b/apps/web/src/features/oceanMap/lib/oceanLayerIds.ts @@ -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; + 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; +} diff --git a/apps/web/src/features/oceanMap/lib/resolveOceanStyle.ts b/apps/web/src/features/oceanMap/lib/resolveOceanStyle.ts new file mode 100644 index 0000000..0a3b2db --- /dev/null +++ b/apps/web/src/features/oceanMap/lib/resolveOceanStyle.ts @@ -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 { + 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).layout as Record | undefined; + if (!layout?.['text-field']) continue; + layout['text-field'] = koTextField; + } +} diff --git a/apps/web/src/features/oceanMap/model/types.ts b/apps/web/src/features/oceanMap/model/types.ts new file mode 100644 index 0000000..e330468 --- /dev/null +++ b/apps/web/src/features/oceanMap/model/types.ts @@ -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', +}; diff --git a/apps/web/src/features/oceanMap/ui/OceanMapSettingsPanel.tsx b/apps/web/src/features/oceanMap/ui/OceanMapSettingsPanel.tsx new file mode 100644 index 0000000..12fc3a0 --- /dev/null +++ b/apps/web/src/features/oceanMap/ui/OceanMapSettingsPanel.tsx @@ -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 = (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 ( + <> + + + {open && ( +
+
Ocean 지도 설정
+ + {/* ── Language ──────────────────────────────── */} +
+
레이블 언어
+ +
+ + {/* ── Depth gradient ────────────────────────── */} +
+
+ 수심 구간 색상 + + {value.depthStops.length > 0 && ( + + 자동채우기 + + )} + 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 ? '기본값' : '커스텀'} + + +
+ {value.depthStops.length === 0 ? ( +
+ 기본 스타일 사용 중 +
+ ) : ( + 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 ( +
+ {depthLabel(stop.depth)} + updateDepthStop(idx, e.target.value)} + disabled={dimmed} + /> + {stop.color} +
+ ); + }) + )} +
+ + {/* ── Depth opacity ─────────────────────────── */} +
+
수심 투명도
+
+ update('depthOpacity', Number(e.target.value))} + className="ms-slider" + /> + {value.depthOpacity.toFixed(2)} +
+
+ + {/* ── Contour lines ─────────────────────────── */} +
+
+ 등심선 + update('contourVisible', !value.contourVisible)} + > + {value.contourVisible ? 'ON' : 'OFF'} + +
+ {value.contourVisible && ( + <> +
+ 색상 + update('contourColor', e.target.value)} + /> +
+
+ 투명도 + update('contourOpacity', Number(e.target.value))} + className="ms-slider" + /> + {value.contourOpacity.toFixed(2)} +
+
+ 굵기 + update('contourWidth', Number(e.target.value))} + className="ms-slider" + /> + {value.contourWidth.toFixed(1)} +
+ + )} +
+ + {/* ── Depth labels ──────────────────────────── */} +
+
+ 수심 라벨 + update('depthLabelsVisible', !value.depthLabelsVisible)} + > + {value.depthLabelsVisible ? 'ON' : 'OFF'} + +
+ {value.depthLabelsVisible && ( + <> +
+ 색상 + update('depthLabelColor', e.target.value)} + /> +
+
+ 크기 +
+ {FONT_SIZES.map((fs) => ( +
update('depthLabelSize', fs.value)} + > + {fs.label} +
+ ))} +
+
+ + )} +
+ + {/* ── Hillshade ─────────────────────────────── */} +
+
+ Hillshade + update('hillshadeVisible', !value.hillshadeVisible)} + > + {value.hillshadeVisible ? 'ON' : 'OFF'} + +
+ {value.hillshadeVisible && ( + <> +
+ 강도 + update('hillshadeExaggeration', Number(e.target.value))} + className="ms-slider" + /> + {value.hillshadeExaggeration.toFixed(2)} +
+
+ 그림자 색 + update('hillshadeColor', e.target.value)} + /> +
+ + )} +
+ + {/* ── Landform labels ───────────────────────── */} +
+
+ 해저 지형명 + update('landformLabelsVisible', !value.landformLabelsVisible)} + > + {value.landformLabelsVisible ? 'ON' : 'OFF'} + +
+ {value.landformLabelsVisible && ( +
+ 색상 + update('landformLabelColor', e.target.value)} + /> +
+ )} +
+ + {/* ── Background ────────────────────────────── */} +
+
배경색
+
+ update('backgroundColor', e.target.value)} + /> + {value.backgroundColor} +
+
+ + {/* ── Reset ─────────────────────────────────── */} + +
+ )} + + ); +} diff --git a/apps/web/src/pages/dashboard/DashboardPage.tsx b/apps/web/src/pages/dashboard/DashboardPage.tsx index d6f31f4..086a3cd 100644 --- a/apps/web/src/pages/dashboard/DashboardPage.tsx +++ b/apps/web/src/pages/dashboard/DashboardPage.tsx @@ -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} /> - - + {baseMap === 'ocean' ? ( + + ) : ( + + )} + {baseMap !== 'ocean' && } {selectedLegacyVessel ? ( setSelectedMmsi(null)} onSelectMmsi={setSelectedMmsi} imo={selectedTarget && selectedTarget.imo > 0 ? selectedTarget.imo : undefined} shipImagePath={selectedTarget?.shipImagePath} shipImageCount={selectedTarget?.shipImageCount} onOpenImageModal={handlePanelOpenImageModal} /> ) : selectedTarget ? ( setSelectedMmsi(null)} onOpenImageModal={handlePanelOpenImageModal} /> ) : null} + {hasUnread && ( + + )} {imageModal && ( 자유 시점 + setBaseMap(baseMap === 'ocean' ? 'enhanced' : 'ocean')} + title="Ocean 전용 지도 (해양 정보 극대화)" + className="px-2 py-0.5 text-[9px]" + > + Ocean + setProjection((p) => (p === 'globe' ? 'mercator' : 'globe'))} diff --git a/apps/web/src/pages/dashboard/useDashboardState.ts b/apps/web/src/pages/dashboard/useDashboardState.ts index efa56d1..2da1242 100644 --- a/apps/web/src/pages/dashboard/useDashboardState.ts +++ b/apps/web/src/pages/dashboard/useDashboardState.ts @@ -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('enhanced'); + const [baseMap, setBaseMap] = usePersistedState(uid, 'baseMap', 'ocean'); const [projection, setProjection] = useState('mercator'); const [mapStyleSettings, setMapStyleSettings] = usePersistedState(uid, 'mapStyleSettings', DEFAULT_MAP_STYLE_SETTINGS); const [overlays, setOverlays] = usePersistedState(uid, 'overlays', { @@ -53,6 +53,7 @@ export function useDashboardState(uid: number | null) { showShips: true, showDensity: false, showSeamark: false, }); const [mapView, setMapView] = usePersistedState(uid, 'mapView', null); + const [oceanMapSettings, setOceanMapSettings] = usePersistedState(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, diff --git a/apps/web/src/widgets/map3d/Map3D.tsx b/apps/web/src/widgets/map3d/Map3D.tsx index 3c30bb8..1aeb1c8 100644 --- a/apps/web/src/widgets/map3d/Map3D.tsx +++ b/apps/web/src/widgets/map3d/Map3D.tsx @@ -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(null); @@ -575,6 +577,7 @@ export function Map3D({ ); useMapStyleSettings(mapRef, mapStyleSettings, { baseMap, mapSyncEpoch }); + useOceanMapSettings(mapRef, oceanMapSettings, { baseMap, mapSyncEpoch }); useZonesLayer( mapRef, projectionBusyRef, reorderGlobeFeatureLayers, diff --git a/apps/web/src/widgets/map3d/hooks/useGlobeShipLayers.ts b/apps/web/src/widgets/map3d/hooks/useGlobeShipLayers.ts index 1a5a267..eaaf29e 100644 --- a/apps/web/src/widgets/map3d/hooks/useGlobeShipLayers.ts +++ b/apps/web/src/widgets/map3d/hooks/useGlobeShipLayers.ts @@ -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, diff --git a/apps/web/src/widgets/map3d/hooks/useMapStyleSettings.ts b/apps/web/src/widgets/map3d/hooks/useMapStyleSettings.ts index 1165ab9..70b6311 100644 --- a/apps/web/src/widgets/map3d/hooks/useMapStyleSettings.ts +++ b/apps/web/src/widgets/map3d/hooks/useMapStyleSettings.ts @@ -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); diff --git a/apps/web/src/widgets/map3d/layers/bathymetry.ts b/apps/web/src/widgets/map3d/layers/bathymetry.ts index 7d5a48a..0ad3e2e 100644 --- a/apps/web/src/widgets/map3d/layers/bathymetry.ts +++ b/apps/web/src/widgets/map3d/layers/bathymetry.ts @@ -390,6 +390,10 @@ export async function resolveInitialMapStyle(signal: AbortSignal): Promise { + 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; diff --git a/apps/web/src/widgets/map3d/lib/deckLayerFactories.ts b/apps/web/src/widgets/map3d/lib/deckLayerFactories.ts index e72a68b..7703556 100644 --- a/apps/web/src/widgets/map3d/lib/deckLayerFactories.ts +++ b/apps/web/src/widgets/map3d/lib/deckLayerFactories.ts @@ -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], }), ); } diff --git a/apps/web/src/widgets/map3d/types.ts b/apps/web/src/widgets/map3d/types.ts index ba66214..af087a7 100644 --- a/apps/web/src/widgets/map3d/types.ts +++ b/apps/web/src/widgets/map3d/types.ts @@ -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 = {