From 12fdae9a2efd410db025329ab4d27a51137134cc Mon Sep 17 00:00:00 2001 From: htlee Date: Fri, 20 Feb 2026 23:14:48 +0900 Subject: [PATCH] =?UTF-8?q?feat(ocean-map):=20Ocean=20=EC=A0=84=EC=9A=A9?= =?UTF-8?q?=20=EC=A7=80=EB=8F=84=20=EB=AA=A8=EB=93=88=20=EC=B6=94=EA=B0=80?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit MapTiler Ocean 완전 스타일 기반 별도 베이스맵 모드. features/oceanMap/ 자체 완결 블록 — 기존 enhanced 코드 변경 없음. - resolveOceanStyle: Ocean style.json fetch + 한국어 라벨 - useOceanMapSettings: 런타임 커스텀 (수심색상/등심선/hillshade/라벨) - OceanMapSettingsPanel: 9개 섹션 설정 UI - 사이드바 Ocean 토글 + 설정 패널 baseMap 분기 - resolveMapStyle dynamic import로 번들 분리 Co-Authored-By: Claude Opus 4.6 --- .claude/skills/mr/SKILL.md | 123 +++++++ .claude/skills/push/SKILL.md | 92 +++++ .claude/skills/release/SKILL.md | 134 +++++++ .../oceanMap/hooks/useOceanMapSettings.ts | 193 ++++++++++ apps/web/src/features/oceanMap/index.ts | 6 + .../features/oceanMap/lib/oceanLayerIds.ts | 77 ++++ .../oceanMap/lib/resolveOceanStyle.ts | 26 ++ apps/web/src/features/oceanMap/model/types.ts | 77 ++++ .../oceanMap/ui/OceanMapSettingsPanel.tsx | 347 ++++++++++++++++++ .../web/src/pages/dashboard/DashboardPage.tsx | 10 +- .../src/pages/dashboard/DashboardSidebar.tsx | 9 + .../src/pages/dashboard/useDashboardState.ts | 11 +- apps/web/src/widgets/map3d/Map3D.tsx | 3 + .../map3d/hooks/useMapStyleSettings.ts | 2 + .../src/widgets/map3d/layers/bathymetry.ts | 4 + apps/web/src/widgets/map3d/types.ts | 5 +- 16 files changed, 1111 insertions(+), 8 deletions(-) create mode 100644 .claude/skills/mr/SKILL.md create mode 100644 .claude/skills/push/SKILL.md create mode 100644 .claude/skills/release/SKILL.md create mode 100644 apps/web/src/features/oceanMap/hooks/useOceanMapSettings.ts create mode 100644 apps/web/src/features/oceanMap/index.ts create mode 100644 apps/web/src/features/oceanMap/lib/oceanLayerIds.ts create mode 100644 apps/web/src/features/oceanMap/lib/resolveOceanStyle.ts create mode 100644 apps/web/src/features/oceanMap/model/types.ts create mode 100644 apps/web/src/features/oceanMap/ui/OceanMapSettingsPanel.tsx 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/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..a326c1b --- /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 } 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..8db9897 --- /dev/null +++ b/apps/web/src/features/oceanMap/model/types.ts @@ -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', +}; 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..ddf25a9 --- /dev/null +++ b/apps/web/src/features/oceanMap/ui/OceanMapSettingsPanel.tsx @@ -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 = (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 ────────────────────────── */} +
+
+ 수심 구간 색상 + + 자동채우기 + +
+ {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..1847b13 100644 --- a/apps/web/src/pages/dashboard/DashboardPage.tsx +++ b/apps/web/src/pages/dashboard/DashboardPage.tsx @@ -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} /> - - + {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} /> diff --git a/apps/web/src/pages/dashboard/DashboardSidebar.tsx b/apps/web/src/pages/dashboard/DashboardSidebar.tsx index cebf63c..e6cbd8a 100644 --- a/apps/web/src/pages/dashboard/DashboardSidebar.tsx +++ b/apps/web/src/pages/dashboard/DashboardSidebar.tsx @@ -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({ > 자유 시점 + 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/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/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 = { -- 2.45.2