feat(ocean-map): Ocean 전용 지도 모듈 추가 #42
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 접근 토큰
|
||||
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 } 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;
|
||||
}
|
||||
}
|
||||
77
apps/web/src/features/oceanMap/model/types.ts
Normal file
77
apps/web/src/features/oceanMap/model/types.ts
Normal file
@ -0,0 +1,77 @@
|
||||
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;
|
||||
}
|
||||
|
||||
export const DEFAULT_OCEAN_MAP_SETTINGS: OceanMapSettings = {
|
||||
depthStops: [
|
||||
{ depth: -11000, color: '#000308' },
|
||||
{ depth: -8000, color: '#010610' },
|
||||
{ depth: -6000, color: '#020a18' },
|
||||
{ depth: -4000, color: '#030c1c' },
|
||||
{ depth: -2000, color: '#041022' },
|
||||
{ depth: -1000, color: '#051529' },
|
||||
{ depth: -500, color: '#061a30' },
|
||||
{ depth: -200, color: '#071f36' },
|
||||
{ depth: -100, color: '#08263d' },
|
||||
{ depth: -50, color: '#0e3d5e' },
|
||||
{ depth: -20, color: '#145578' },
|
||||
{ depth: 0, color: '#2097a6' },
|
||||
],
|
||||
depthOpacity: 0.88,
|
||||
|
||||
contourVisible: true,
|
||||
contourColor: '#ffffff',
|
||||
contourOpacity: 0.2,
|
||||
contourWidth: 0.8,
|
||||
|
||||
depthLabelsVisible: true,
|
||||
depthLabelColor: '#e2e8f0',
|
||||
depthLabelSize: 'medium',
|
||||
|
||||
hillshadeVisible: true,
|
||||
hillshadeExaggeration: 0.5,
|
||||
hillshadeColor: '#000020',
|
||||
|
||||
landformLabelsVisible: true,
|
||||
landformLabelColor: '#94a3b8',
|
||||
|
||||
backgroundColor: '#010610',
|
||||
|
||||
labelLanguage: 'ko',
|
||||
};
|
||||
347
apps/web/src/features/oceanMap/ui/OceanMapSettingsPanel.tsx
Normal file
347
apps/web/src/features/oceanMap/ui/OceanMapSettingsPanel.tsx
Normal file
@ -0,0 +1,347 @@
|
||||
import { useState } from 'react';
|
||||
import type { OceanMapSettings, OceanLabelLanguage, OceanDepthLabelSize, OceanDepthStop } from '../model/types';
|
||||
import { DEFAULT_OCEAN_MAP_SETTINGS } 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
|
||||
className={`ml-2 cursor-pointer rounded border px-1.5 py-px text-[8px] transition-all duration-150 select-none ${autoGradient ? 'border-wing-accent bg-wing-accent text-white' : 'border-wing-border bg-wing-card text-wing-muted'}`}
|
||||
onClick={toggleAutoGradient}
|
||||
title="최소/최대 색상 기준으로 중간 구간을 자동 보간합니다"
|
||||
>
|
||||
자동채우기
|
||||
</span>
|
||||
</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,7 @@ 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 {
|
||||
buildLegacyHitMap,
|
||||
computeCountsByType,
|
||||
@ -412,6 +413,7 @@ export function DashboardPage() {
|
||||
alarmMmsiMap={alarmMmsiMap}
|
||||
onClickShipPhoto={handleOpenImageModal}
|
||||
freeCamera={state.freeCamera}
|
||||
oceanMapSettings={state.oceanMapSettings}
|
||||
/>
|
||||
<GlobalTrackReplayPanel />
|
||||
<WeatherPanel
|
||||
@ -421,8 +423,12 @@ 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} />
|
||||
|
||||
@ -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,
|
||||
|
||||
@ -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;
|
||||
|
||||
@ -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