Compare commits

...

34 커밋

작성자 SHA1 메시지 날짜
4d1a8a0d1e Merge pull request 'feat(weather,track): 기상 오버레이 + vessel-track 병합' (#17) from feature/weather-overlay into develop
Reviewed-on: #17
2026-02-16 22:47:26 +09:00
86a0b2276f fix(web): vessel-track 안정화 반영
- Mercator/Globe track-replay 레이어 충돌 및 setProps 레이스 해결
- track DTO 좌표/시간 정규화 + stale query 응답 무시
- 조회 직후 표시 안정화 및 기본 100x 자동재생 적용
- Global Track Replay 패널 초기 위치 조정 + 헤더 드래그 지원
- liveRenderer batch rendering + trackReplay store 기반 구조 반영

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-16 22:43:08 +09:00
c09211d5ed chore: develop 병합으로 충돌 해결
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-16 22:21:15 +09:00
6acf2045b2 chore: vessel-track 브랜치 병합 (squash)
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-16 22:12:48 +09:00
cb6493b8a1 feat(weather): 기상 타일 오버레이 + 수역별 날씨 패널
MapTiler Weather SDK 6종 기상 타일 오버레이:
- 바람/기온/강수/기압/레이더/구름 라디오 토글
- 3시간 단위 step 스냅 타임라인 + 드래그 실시간 seek
- 색상 범례, 배속 제어, 투명도 조절
- ServiceWorker 타일 캐시 (cache-first, 최대 2000장)
- SDK 시간 단위(epoch 초) 정합성 보장

Open-Meteo 수역별 기상 패널:
- 4개 수역 centroid 기반 해양/기상 데이터 5분 폴링
- 파고/풍속/수온/너울 카드 UI + 경고 하이라이트

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-16 21:45:07 +09:00
c05ec159ce Merge pull request 'feat(map): 항적조회 기능 구현' (#14) from feature/vessel-track into develop
Reviewed-on: #14
2026-02-16 18:33:18 +09:00
69775c90a2 feat(map): 항적조회 + SVG 캐시 + fitBounds
- 대상선박 우클릭 컨텍스트 메뉴로 항적 조회 (6h~5d)
- Mercator: PathLayer(고정) + TripsLayer(애니메이션) + ScatterplotLayer(포인트)
- Globe: MapLibre 네이티브 line + arrow + circle 레이어
- rAF 직접 overlay 조작으로 React 재렌더링 방지
- SVG 아이콘 data URL 캐시로 네트워크 재요청 방지
- 항적 조회 시 자동 fitBounds (전체 항적 뷰포트 맞춤)
- API 프록시 /api/ais-target/:mmsi/track 엔드포인트 추가

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-16 18:19:01 +09:00
c23264c3ba Merge pull request 'feature/dual-rendering' (#12) from feature/dual-rendering into develop 2026-02-16 16:26:10 +09:00
c423b59244 Merge remote-tracking branch 'origin/develop' into feature/dual-rendering
# Conflicts:
#	apps/web/src/pages/dashboard/DashboardPage.tsx
2026-02-16 16:25:38 +09:00
fe5ec7100b docs: CLAUDE.md 최신 구조 반영
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-16 16:24:52 +09:00
c03dee0ade fix(map): globe 노란벽 해결 + 심해색 복구
- deck-globe 렌더 파이프라인 비활성화로 노란벽 해소
- 베이스 수색 원복 (#14606e/#114f5c)
- globe 필터 로직 제거 (불필요)

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-16 16:04:58 +09:00
1fd9f3da82 fix(map): fill 단일화 + globe 배경색 심해색 통일
- fill 3-tier 제거 → 단일 레이어(전체 depth) 복원
- setSky: sky/horizon/fog를 심해색(#010610)으로 설정
- 캔버스/map-area 배경: #010610 (타일 gap seam 비가시화)
- 타일 경계 gap으로 배경이 비칠 때 색상 차이를 제거

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-16 15:34:49 +09:00
3a001ca9b6 fix(map): fill 3-tier LOD로 타일 seam 해결
- 심해 fill 폴리곤이 globe 타일 경계에서 seam 아티팩트 발생
- bathymetry-fill: z3-7 (depth >= -2000, 천해만)
- bathymetry-fill-medium: z7-9 (depth >= -4000)
- bathymetry-fill-deep: z9+ (전체 depth)
- applyDepthGradient: 3개 fill 레이어 모두 적용

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-16 15:28:23 +09:00
2095503e50 fix(map): 심해 등심선 복원 + 전체 depth LOD
- shallowFilter 제거: 전체 depth 범위 렌더링 복원
- bathyFillColor: -8000m, -4000m 색상 stop 복원
- DEPTHS_MEDIUM: -4000m 추가 (z7-9)
- DEPTHS_DETAIL: -4000m, -8000m 추가 (z9+)
- 줌 기반 LOD가 vertex 제어 담당 (depth 필터 불필요)

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-16 15:21:46 +09:00
f50c227fd4 fix(map): globe 모드 수역 fill/text 복구
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-16 15:15:45 +09:00
b022e4bc36 perf(map): 줌 기반 LOD + 심해 등심선 제거
- applyLandLayerLOD: 베이스맵 육지 레이어에 minzoom 적용
  (landcover z9, transportation z8, building z14 등)
- 수심 3-tier LOD: coarse(z3-7), medium(z7-9), detail(z9+)
- shallowFilter: depth >= -2000, 심해 feature GPU 미전달
- applyDepthGradient ascending order 에러 수정
- vertex 경고 passthrough (디버깅용 유지)

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-16 15:15:31 +09:00
d5700ba587 fix(map): zone 간소화를 projectionBusy 앞으로 이동
소스 데이터 간소화가 projectionBusy 가드 뒤에 있어서
globe 전환 시 원본 데이터(2100+ vertex)로 tessellation 진행 →
73,000+ vertex 폭증. setData를 가드 앞으로 이동하고
useMemo로 간소화 데이터 캐싱.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-16 13:59:21 +09:00
7bec1ae86d fix(map): globe 수역 line vertex 초과 해결
zones-line도 globe tessellation에서 73,300+ vertex로 폭증.
globe 모드에서 수역 소스 데이터를 ring당 60점으로 서브샘플링.
원본 2100+ vertex → ~240 vertex → globe tessellation 후 65535 이내.
mercator 모드에서는 원본 데이터 유지.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-16 13:44:26 +09:00
99d714582b fix(map): globe 모드 zones-fill 숨김 + 라벨 가드
- globe tessellation에서 수역 fill polygon vertex 65535 초과
  (해안선 2100 vertex → globe에서 108890+로 폭증) → 노란 막대
- globe 모드에서 zones-fill visibility: none으로 설정
- guardedSetVisibility 적용으로 수역 라벨 사라짐 방지

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-16 13:38:37 +09:00
95d9ea8aef fix(map): 라벨 사라짐 + easing 경고 + vertex 경고 수정
- guardedSetVisibility 도입: 현재 값과 동일하면 setLayoutProperty
  호출 생략하여 style._changed 트리거 방지 → symbol 재배치로 인한
  text-allow-overlap:false 라벨 사라짐 현상 해결
- useGlobeShips 기존 레이어 else 블록의 중복 expression 재설정 제거
  (data-driven 표현식은 addLayer 시 1회 설정으로 충분)
- _render 래퍼에서 globe scrollZoom easing 경고 억제
- fleet-circles-ml-fill 레이어 완전 제거 (vertex 65535 초과 원인)

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-16 13:34:42 +09:00
91df90b528 perf(map): Globe/Mercator 양방향 동시 렌더링
- overlay 파괴/재생성 대신 layers 비움으로 전환
- globe ship 레이어 visibility 즉시 토글 (projectionBusy 우회)
- fleet circles fill vertex 초과 수정 (steps 72→36/24)
- globe scrollZoom easing 경고 수정
- projection 비영속화 (항상 mercator 시작)
- globe 레이어 준비 전까지 3D 토글 비활성화

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-16 13:08:54 +09:00
4cf0f20504 Merge branch 'feature/globe-ship-precompute' into develop 2026-02-16 12:26:47 +09:00
d88c89403d perf(map): globe 선박 렌더링 사전계산 최적화
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-16 12:26:42 +09:00
77e3a531e8 Merge branch 'feature/chnprmship-polling' into develop 2026-02-16 12:23:23 +09:00
39d9cc9db1 feat(ais): 초기 로드를 chnprmship API로 전환
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-16 12:23:18 +09:00
8dc216ae1c Merge branch 'feature/persist-user-state' into develop 2026-02-16 12:18:46 +09:00
16ebf3abca feat: 사용자 설정 영속화 + 지도 뷰 저장
usePersistedState hook으로 대시보드 상태를
localStorage에 자동 저장. 지도 뷰(중심/줌/방위)도
60초 주기 + 언마운트 시 저장하여 새로고침 복원.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-16 12:18:39 +09:00
bd3b3f9a8c Merge branch 'feature/kst-timestamp' into develop 2026-02-16 12:12:50 +09:00
cc807bb5f6 feat: KST 타임스탬프 포맷 유틸 적용
shared/lib/datetime.ts에 KST 고정 포맷 함수 추가.
AIS 정보, 선박 목록, 대시보드 등의 날짜 표시를
로컬 포맷에서 KST 명시적 포맷으로 통일.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-16 12:12:43 +09:00
ca43bbcebb Merge pull request 'feat(auth): Google OAuth 로그인 구현' (#7) from feature/auth-login into develop 2026-02-16 08:48:24 +09:00
20db4cb0cf style(auth): 로그인 페이지 UI 조정
- 로고: WING → GC WING + demo 라벨
- 하단: GC Surveillance → GC SI Team

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-16 08:47:50 +09:00
79b21c7d44 feat(auth): Google OAuth 로그인 구현
- shared/auth 모듈: AuthProvider, ProtectedRoute, useAuth, authApi
- 페이지: LoginPage(Google OAuth), PendingPage, DeniedPage
- WING_PERMIT 역할 기반 접근 제어
- Topbar에 사용자 이름 + 로그아웃 버튼 추가
- App.tsx에 react-router 라우팅 + AuthProvider 래핑
- DEV 모드 Mock 로그인 지원 (김개발)

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-16 08:44:25 +09:00
22099862e6 Merge pull request 'ci: 빌드 시 MapTiler API 키 환경변수 전달' (#5) from ci/actions-trigger into develop 2026-02-16 07:52:35 +09:00
fd3cedc6c1 Merge pull request 'ci: Actions 활성화 후 CI/CD 트리거 확인' (#3) from ci/actions-trigger into develop 2026-02-16 07:39:45 +09:00
79개의 변경된 파일8231개의 추가작업 그리고 863개의 파일을 삭제

파일 보기

@ -29,6 +29,8 @@ jobs:
env: env:
VITE_MAPTILER_KEY: ${{ secrets.MAPTILER_KEY }} VITE_MAPTILER_KEY: ${{ secrets.MAPTILER_KEY }}
VITE_MAPTILER_BASE_MAP_ID: dataviz-dark VITE_MAPTILER_BASE_MAP_ID: dataviz-dark
VITE_AUTH_API_URL: https://guide.gc-si.dev
VITE_GOOGLE_CLIENT_ID: ${{ secrets.GOOGLE_CLIENT_ID }}
run: npm -w @wing/web run build run: npm -w @wing/web run build
- name: Deploy to server - name: Deploy to server

파일 보기

@ -2,34 +2,22 @@
## 프로젝트 개요 ## 프로젝트 개요
- **타입**: React + TypeScript + Vite (모노레포) - **타입**: React 19 + TypeScript 5.9 + Vite 7 (모노레포)
- **Node.js**: `.node-version` 참조 (v24) - **Node.js**: `.node-version` 참조 (v24)
- **패키지 매니저**: npm (workspaces) - **패키지 매니저**: npm (workspaces)
- **구조**: apps/web (프론트엔드) + apps/api (백엔드 API) - **구조**: apps/web (프론트엔드) + apps/api (백엔드 API 프록시)
## 빌드 및 실행 ## 빌드 및 실행
```bash ```bash
# 의존성 설치 npm install # 의존성 설치
npm install npm run dev # 전체 개발 서버
# 전체 개발 서버
npm run dev
# 개별 개발 서버
npm run dev:web # 프론트엔드 (Vite) npm run dev:web # 프론트엔드 (Vite)
npm run dev:api # 백엔드 (Fastify + tsx watch) npm run dev:api # 백엔드 (Fastify + tsx watch)
# 빌드
npm run build # 전체 빌드 (web + api) npm run build # 전체 빌드 (web + api)
npm run build:web # 프론트엔드만 npm run build:web # 프론트엔드만
npm run build:api # 백엔드만
# 린트
npm run lint # apps/web ESLint npm run lint # apps/web ESLint
npm run prepare:data # 정적 데이터 준비
# 데이터 준비
npm run prepare:data
``` ```
## 프로젝트 구조 ## 프로젝트 구조
@ -37,19 +25,18 @@ npm run prepare:data
``` ```
gc-wing-dev/ gc-wing-dev/
├── apps/ ├── apps/
│ ├── web/ # React 19 + Vite 7 + MapLibre + Deck.gl │ ├── web/ # @wing/web - React 19 + Vite 7
│ │ └── src/ │ │ └── src/
│ │ ├── app/ # App.tsx, styles │ │ ├── app/ # App.tsx, styles.css
│ │ ├── entities/ # 도메인 모델 (vessel, zone, aisTarget, legacyVessel) │ │ ├── entities/ # 도메인 모델 (aisTarget, vessel, zone, legacyVessel, subcable)
│ │ ├── features/ # 기능 단위 (mapToggles, typeFilter, aisPolling 등) │ │ ├── features/ # 기능 모듈 (aisPolling, legacyDashboard, map3dSettings, mapSettings, mapToggles, typeFilter)
│ │ ├── pages/ # 페이지 (DashboardPage) │ │ ├── pages/ # dashboard, login, denied, pending
│ │ ├── shared/ # 공통 유틸 (lib/geo, lib/color, lib/map) │ │ ├── shared/ # auth (Google OAuth), lib (geo, color, map), hooks (usePersistedState)
│ │ └── widgets/ # UI 위젯 (map3d, vesselList, info, alarms 등) │ │ └── widgets/ # map3d, vesselList, info, alarms, relations, aisInfo, aisTargetList, topbar, speed, legend, subcableInfo
│ └── api/ # Fastify 5 + TypeScript │ └── api/ # @wing/api - Fastify 5
│ └── src/ │ └── src/index.ts # AIS 프록시 + zones 엔드포인트
│ └── index.ts
├── data/ # 정적 데이터 ├── data/ # 정적 데이터
├── scripts/ # 빌드 스크립트 (prepare-zones, prepare-legacy) ├── scripts/ # prepare-zones.mjs, prepare-legacy.mjs
└── legacy/ # 레거시 데이터 └── legacy/ # 레거시 데이터
``` ```
@ -59,6 +46,7 @@ gc-wing-dev/
|------|------| |------|------|
| 프론트엔드 | React 19, Vite 7, TypeScript 5.9 | | 프론트엔드 | React 19, Vite 7, TypeScript 5.9 |
| 지도 | MapLibre GL JS 5, Deck.gl 9 | | 지도 | MapLibre GL JS 5, Deck.gl 9 |
| 인증 | Google OAuth (AuthProvider + ProtectedRoute) |
| 백엔드 | Fastify 5, TypeScript | | 백엔드 | Fastify 5, TypeScript |
| 린트 | ESLint 9, Prettier | | 린트 | ESLint 9, Prettier |

파일 보기

@ -131,6 +131,52 @@ function parseBbox(raw: string | undefined) {
return { lonMin, latMin, lonMax, latMax }; return { lonMin, latMin, lonMax, latMax };
} }
app.get<{
Params: { mmsi: string };
Querystring: { minutes?: string };
}>("/api/ais-target/:mmsi/track", async (req, reply) => {
const mmsiRaw = req.params.mmsi;
const mmsi = Number(mmsiRaw);
if (!Number.isFinite(mmsi) || mmsi <= 0 || !Number.isInteger(mmsi)) {
return reply.code(400).send({ success: false, message: "invalid mmsi", data: [], errorCode: "BAD_REQUEST" });
}
const minutesRaw = req.query.minutes ?? "360";
const minutes = Number(minutesRaw);
if (!Number.isFinite(minutes) || minutes <= 0 || minutes > 7200) {
return reply.code(400).send({ success: false, message: "invalid minutes (1-7200)", data: [], errorCode: "BAD_REQUEST" });
}
const u = new URL(`/snp-api/api/ais-target/${mmsi}/track`, AIS_UPSTREAM_BASE);
u.searchParams.set("minutes", String(minutes));
const controller = new AbortController();
const timeoutMs = 20_000;
const timeout = setTimeout(() => controller.abort(), timeoutMs);
try {
const res = await fetch(u, { signal: controller.signal, headers: { accept: "application/json" } });
const txt = await res.text();
if (!res.ok) {
req.log.warn({ status: res.status, body: txt.slice(0, 2000) }, "Track upstream error");
return reply.code(502).send({ success: false, message: "upstream error", data: [], errorCode: "UPSTREAM" });
}
reply.type("application/json").send(txt);
} catch (e) {
const name = e instanceof Error ? e.name : "";
const isTimeout = name === "AbortError";
req.log.warn({ err: e, url: u.toString() }, "Track proxy request failed");
return reply.code(isTimeout ? 504 : 502).send({
success: false,
message: isTimeout ? `upstream timeout (${timeoutMs}ms)` : "upstream fetch failed",
data: [],
errorCode: isTimeout ? "UPSTREAM_TIMEOUT" : "UPSTREAM_FETCH_FAILED",
});
} finally {
clearTimeout(timeout);
}
});
app.get("/zones", async (_req, reply) => { app.get("/zones", async (_req, reply) => {
const zonesPath = path.resolve( const zonesPath = path.resolve(
process.cwd(), process.cwd(),

파일 보기

@ -12,11 +12,18 @@
"dependencies": { "dependencies": {
"@deck.gl/aggregation-layers": "^9.2.7", "@deck.gl/aggregation-layers": "^9.2.7",
"@deck.gl/core": "^9.2.7", "@deck.gl/core": "^9.2.7",
"@deck.gl/geo-layers": "^9.2.7",
"@deck.gl/layers": "^9.2.7", "@deck.gl/layers": "^9.2.7",
"@deck.gl/mapbox": "^9.2.7", "@deck.gl/mapbox": "^9.2.7",
"@maptiler/weather": "^3.1.1",
"@react-oauth/google": "^0.13.4",
"maplibre-gl": "^5.18.0", "maplibre-gl": "^5.18.0",
"react": "^19.2.0", "react": "^19.2.0",
"react-dom": "^19.2.0" "react-dom": "^19.2.0",
"react-router": "^7.13.0",
"@deck.gl/extensions": "^9.2.7",
"@stomp/stompjs": "^7.2.1",
"zustand": "^5.0.8"
}, },
"devDependencies": { "devDependencies": {
"@eslint/js": "^9.39.1", "@eslint/js": "^9.39.1",

파일 보기

@ -0,0 +1,62 @@
/**
* Weather Tile Cache ServiceWorker
*
* MapTiler Weather SDK 타일(Image 요소로 로드) Cache API로 캐싱.
* 같은 tileset_id + z/x/y 좌표 동일 URL cache-first 전략.
*
* 캐시 최대 2000, 초과 가장 오래된 항목부터 제거.
*/
const CACHE_NAME = 'weather-tiles-v1';
const MAX_ENTRIES = 2000;
/** api.maptiler.com/tiles/ 패턴만 캐싱 */
const TILE_RE = /api\.maptiler\.com\/tiles\//;
self.addEventListener('install', () => {
self.skipWaiting();
});
self.addEventListener('activate', (event) => {
event.waitUntil(
caches.keys().then((keys) =>
Promise.all(
keys
.filter((k) => k.startsWith('weather-tiles-') && k !== CACHE_NAME)
.map((k) => caches.delete(k)),
),
),
);
self.clients.claim();
});
self.addEventListener('fetch', (event) => {
const url = event.request.url;
if (!TILE_RE.test(url)) return; // 타일 외 요청은 패스스루
event.respondWith(
caches.open(CACHE_NAME).then(async (cache) => {
// 1. 캐시 히트 → 즉시 반환
const cached = await cache.match(event.request);
if (cached) return cached;
// 2. 네트워크 fetch
const response = await fetch(event.request);
if (response.ok) {
// 비동기 캐시 저장 (응답 지연 없음)
cache.put(event.request, response.clone()).then(() => trimCache(cache));
}
return response;
}),
);
});
/** 캐시 항목이 MAX_ENTRIES 초과 시 오래된 것부터 삭제 */
async function trimCache(cache) {
const keys = await cache.keys();
if (keys.length <= MAX_ENTRIES) return;
const excess = keys.length - MAX_ENTRIES;
for (let i = 0; i < excess; i++) {
await cache.delete(keys[i]);
}
}

파일 보기

@ -1,6 +1,23 @@
import { BrowserRouter, Route, Routes } from "react-router";
import { AuthProvider, ProtectedRoute } from "../shared/auth";
import { DashboardPage } from "../pages/dashboard/DashboardPage"; import { DashboardPage } from "../pages/dashboard/DashboardPage";
import { LoginPage } from "../pages/login/LoginPage";
import { PendingPage } from "../pages/pending/PendingPage";
import { DeniedPage } from "../pages/denied/DeniedPage";
export default function App() { export default function App() {
return <DashboardPage />; return (
<AuthProvider>
<BrowserRouter>
<Routes>
<Route path="/login" element={<LoginPage />} />
<Route path="/pending" element={<PendingPage />} />
<Route path="/denied" element={<DeniedPage />} />
<Route element={<ProtectedRoute />}>
<Route index element={<DashboardPage />} />
</Route>
</Routes>
</BrowserRouter>
</AuthProvider>
);
} }

파일 보기

@ -105,6 +105,7 @@ body {
.map-area { .map-area {
position: relative; position: relative;
background: #010610;
} }
.sb { .sb {
@ -1097,6 +1098,710 @@ body {
padding: 1px 0; padding: 1px 0;
} }
/* ── Auth pages ──────────────────────────────────────────────────── */
.auth-page {
height: 100vh;
display: flex;
align-items: center;
justify-content: center;
background: linear-gradient(135deg, #020617 0%, #0f172a 50%, #020617 100%);
}
.auth-card {
background: var(--panel);
border: 1px solid var(--border);
border-radius: 16px;
padding: 40px 36px;
width: 360px;
text-align: center;
}
.auth-logo {
font-size: 36px;
font-weight: 900;
color: var(--accent);
letter-spacing: 4px;
margin-bottom: 4px;
}
.auth-title {
font-size: 16px;
font-weight: 700;
color: var(--text);
margin-bottom: 8px;
}
.auth-subtitle {
font-size: 12px;
color: var(--muted);
margin-bottom: 24px;
}
.auth-error {
font-size: 11px;
color: var(--crit);
background: rgba(239, 68, 68, 0.08);
border: 1px solid rgba(239, 68, 68, 0.2);
border-radius: 8px;
padding: 8px 12px;
margin-bottom: 16px;
}
.auth-google-btn {
display: flex;
justify-content: center;
margin-bottom: 12px;
}
.auth-dev-btn {
width: 100%;
padding: 10px;
border-radius: 8px;
border: 1px solid var(--border);
background: var(--card);
color: var(--muted);
font-size: 12px;
cursor: pointer;
transition: all 0.15s;
margin-bottom: 12px;
}
.auth-dev-btn:hover {
color: var(--text);
border-color: var(--accent);
}
.auth-footer {
font-size: 10px;
color: var(--muted);
margin-top: 16px;
}
.auth-status-icon {
font-size: 48px;
margin-bottom: 12px;
}
.auth-message {
font-size: 13px;
color: var(--muted);
line-height: 1.6;
margin-bottom: 16px;
}
.auth-message b {
color: var(--text);
}
.auth-link-btn {
background: none;
border: none;
color: var(--accent);
font-size: 12px;
cursor: pointer;
text-decoration: underline;
padding: 4px 8px;
}
.auth-link-btn:hover {
color: var(--text);
}
.auth-loading {
height: 100vh;
display: flex;
align-items: center;
justify-content: center;
background: var(--bg);
}
.auth-loading__spinner {
width: 32px;
height: 32px;
border: 3px solid rgba(148, 163, 184, 0.28);
border-top-color: var(--accent);
border-radius: 50%;
animation: map-loader-spin 0.7s linear infinite;
}
/* ── Topbar user ─────────────────────────────────────────────────── */
.topbar-user {
display: flex;
align-items: center;
gap: 8px;
margin-left: 10px;
flex-shrink: 0;
}
.topbar-user__name {
font-size: 10px;
color: var(--text);
font-weight: 500;
white-space: nowrap;
}
.topbar-user__logout {
font-size: 9px;
color: var(--muted);
background: none;
border: 1px solid var(--border);
border-radius: 3px;
padding: 2px 6px;
cursor: pointer;
transition: all 0.15s;
white-space: nowrap;
}
.topbar-user__logout:hover {
color: var(--text);
border-color: var(--accent);
}
/* ── Weather Overlay Panel ─────────────────────────────────── */
.weather-gear {
position: absolute;
top: 140px;
left: 10px;
z-index: 850;
width: 29px;
height: 29px;
border-radius: 4px;
border: 1px solid var(--border);
background: rgba(15, 23, 42, 0.92);
backdrop-filter: blur(8px);
color: var(--muted);
cursor: pointer;
display: flex;
align-items: center;
justify-content: center;
font-size: 16px;
transition: color 0.15s, border-color 0.15s;
user-select: none;
padding: 0;
}
.weather-gear:hover {
color: var(--text);
border-color: var(--accent);
}
.weather-gear.open {
color: var(--accent);
border-color: var(--accent);
}
.weather-panel {
position: absolute;
top: 130px;
left: 48px;
z-index: 850;
background: rgba(15, 23, 42, 0.95);
backdrop-filter: blur(8px);
border: 1px solid var(--border);
border-radius: 8px;
padding: 10px;
width: 260px;
max-height: calc(100vh - 200px);
overflow-y: auto;
}
.wp-header {
display: flex;
align-items: center;
gap: 8px;
margin-bottom: 8px;
}
.wp-title {
font-size: 11px;
font-weight: 700;
color: var(--text);
letter-spacing: 1px;
}
.wp-loading {
font-size: 9px;
color: var(--accent);
}
.wp-error {
font-size: 9px;
color: #f87171;
margin-bottom: 6px;
padding: 4px 6px;
background: rgba(248, 113, 113, 0.1);
border-radius: 4px;
}
.wp-empty {
font-size: 10px;
color: var(--muted);
text-align: center;
padding: 12px 0;
}
.wz-card {
border-left: 3px solid var(--border);
padding: 6px 8px;
margin-bottom: 6px;
border-radius: 0 4px 4px 0;
background: rgba(255, 255, 255, 0.03);
transition: background 0.15s;
}
.wz-card:last-of-type {
margin-bottom: 0;
}
.wz-card.wz-warn {
background: rgba(248, 113, 113, 0.08);
}
.wz-name {
font-size: 10px;
font-weight: 600;
color: var(--text);
margin-bottom: 4px;
letter-spacing: 0.3px;
}
.wz-row {
display: flex;
gap: 10px;
flex-wrap: wrap;
margin-bottom: 2px;
}
.wz-item {
display: inline-flex;
align-items: center;
gap: 3px;
font-size: 10px;
color: var(--muted);
white-space: nowrap;
}
.wz-icon {
font-size: 10px;
color: var(--accent);
}
.wz-label {
font-size: 9px;
color: var(--muted);
}
.wz-value {
font-size: 10px;
font-weight: 600;
color: var(--text);
}
.wz-weather {
font-weight: 500;
color: var(--muted);
}
.wp-footer {
display: flex;
align-items: center;
justify-content: space-between;
margin-top: 8px;
padding-top: 6px;
border-top: 1px solid var(--border);
}
.wp-time {
font-size: 9px;
color: var(--muted);
}
.wp-refresh {
font-size: 14px;
color: var(--muted);
background: none;
border: 1px solid var(--border);
border-radius: 3px;
width: 22px;
height: 22px;
cursor: pointer;
display: flex;
align-items: center;
justify-content: center;
transition: color 0.15s, border-color 0.15s;
padding: 0;
}
.wp-refresh:hover {
color: var(--text);
border-color: var(--accent);
}
.wp-refresh:disabled {
opacity: 0.5;
cursor: default;
}
/* ── Weather Overlay Panel (MapTiler) ────────────────────────────── */
.wo-gear {
position: absolute;
top: 180px;
left: 10px;
z-index: 850;
width: 29px;
height: 29px;
border-radius: 4px;
border: 1px solid var(--border);
background: rgba(15, 23, 42, 0.92);
backdrop-filter: blur(8px);
color: var(--muted);
cursor: pointer;
display: flex;
align-items: center;
justify-content: center;
font-size: 16px;
transition: color 0.15s, border-color 0.15s;
user-select: none;
padding: 0;
}
.wo-gear:hover {
color: var(--text);
border-color: var(--accent);
}
.wo-gear.open {
color: var(--accent);
border-color: var(--accent);
}
.wo-gear.active {
border-color: #22c55e;
}
.wo-gear.active.open {
border-color: var(--accent);
}
.wo-gear-badge {
position: absolute;
top: -4px;
right: -4px;
background: #22c55e;
color: #fff;
font-size: 8px;
font-weight: 700;
width: 14px;
height: 14px;
border-radius: 50%;
display: flex;
align-items: center;
justify-content: center;
line-height: 1;
}
.wo-stack {
position: absolute;
top: 170px;
left: 48px;
z-index: 850;
display: flex;
flex-direction: column;
gap: 6px;
width: 280px;
pointer-events: none;
}
.wo-stack > * {
pointer-events: auto;
}
.wo-panel {
background: rgba(15, 23, 42, 0.95);
backdrop-filter: blur(8px);
border: 1px solid var(--border);
border-radius: 8px;
padding: 10px;
width: 100%;
max-height: calc(100vh - 240px);
overflow-y: auto;
}
.wo-header {
display: flex;
align-items: center;
gap: 8px;
margin-bottom: 10px;
}
.wo-title {
font-size: 11px;
font-weight: 700;
color: var(--text);
letter-spacing: 1px;
}
.wo-loading {
font-size: 9px;
color: var(--accent);
animation: wo-pulse 1.2s ease-in-out infinite;
}
@keyframes wo-pulse {
0%, 100% { opacity: 1; }
50% { opacity: 0.4; }
}
.wo-layers {
display: grid;
grid-template-columns: repeat(3, 1fr);
gap: 4px;
margin-bottom: 10px;
}
.wo-layer-btn {
display: flex;
flex-direction: column;
align-items: center;
gap: 2px;
padding: 6px 4px;
border-radius: 6px;
border: 1px solid var(--border);
background: rgba(255, 255, 255, 0.03);
color: var(--muted);
cursor: pointer;
transition: all 0.15s;
font-size: 10px;
}
.wo-layer-btn:hover {
background: rgba(255, 255, 255, 0.06);
color: var(--text);
border-color: rgba(59, 130, 246, 0.4);
}
.wo-layer-btn.on {
background: rgba(59, 130, 246, 0.15);
color: var(--text);
border-color: var(--accent);
}
.wo-layer-icon {
font-size: 16px;
line-height: 1;
}
.wo-layer-name {
font-size: 9px;
font-weight: 600;
letter-spacing: 0.3px;
}
.wo-section {
margin-bottom: 8px;
}
.wo-label {
font-size: 9px;
font-weight: 600;
color: var(--text);
margin-bottom: 4px;
display: flex;
align-items: center;
justify-content: space-between;
}
.wo-val {
font-weight: 400;
color: var(--muted);
font-size: 9px;
}
.wo-offset {
color: #4fc3f7;
font-weight: 600;
}
.wo-slider {
width: 100%;
height: 4px;
-webkit-appearance: none;
appearance: none;
background: var(--border);
border-radius: 2px;
outline: none;
cursor: pointer;
}
.wo-slider::-webkit-slider-thumb {
-webkit-appearance: none;
width: 12px;
height: 12px;
border-radius: 50%;
background: var(--accent);
border: 2px solid var(--panel);
cursor: pointer;
}
.wo-slider::-moz-range-thumb {
width: 12px;
height: 12px;
border-radius: 50%;
background: var(--accent);
border: 2px solid var(--panel);
cursor: pointer;
}
.wo-slider:disabled {
opacity: 0.4;
cursor: default;
}
.wo-timeline {
padding-top: 8px;
border-top: 1px solid var(--border);
}
.wo-step-slider-wrap {
position: relative;
padding-bottom: 10px;
}
.wo-time-slider {
margin-bottom: 2px;
}
.wo-step-ticks {
position: absolute;
left: 0;
right: 0;
bottom: 0;
height: 8px;
pointer-events: none;
}
.wo-step-tick {
position: absolute;
bottom: 0;
width: 1px;
height: 4px;
background: var(--muted);
opacity: 0.4;
transform: translateX(-0.5px);
}
.wo-step-tick.day {
height: 8px;
opacity: 0.8;
background: var(--accent);
}
.wo-time-range {
display: flex;
justify-content: space-between;
font-size: 8px;
color: var(--muted);
margin-bottom: 6px;
}
.wo-playback {
display: flex;
align-items: center;
gap: 6px;
}
.wo-play-btn {
width: 26px;
height: 26px;
border-radius: 50%;
border: 1px solid var(--border);
background: var(--card);
color: var(--text);
cursor: pointer;
display: flex;
align-items: center;
justify-content: center;
font-size: 11px;
transition: all 0.15s;
padding: 0;
flex-shrink: 0;
}
.wo-play-btn:hover {
border-color: var(--accent);
background: rgba(59, 130, 246, 0.12);
}
.wo-play-btn:disabled {
opacity: 0.4;
cursor: default;
}
.wo-speed-btns {
display: flex;
gap: 2px;
}
.wo-speed-btn {
font-size: 8px;
padding: 3px 6px;
border-radius: 3px;
border: 1px solid var(--border);
background: transparent;
color: var(--muted);
cursor: pointer;
transition: all 0.15s;
}
.wo-speed-btn:hover {
color: var(--text);
border-color: rgba(59, 130, 246, 0.4);
}
.wo-speed-btn.on {
background: rgba(59, 130, 246, 0.15);
color: var(--text);
border-color: var(--accent);
}
.wo-hint {
font-size: 8px;
color: var(--muted);
text-align: right;
margin-top: 4px;
opacity: 0.6;
}
/* ── Weather Legend ── */
.wo-legend {
background: rgba(15, 23, 42, 0.85);
backdrop-filter: blur(8px);
border: 1px solid var(--border);
border-radius: 6px;
padding: 6px 10px 4px;
width: 100%;
}
.wo-legend-header {
font-size: 9px;
color: var(--muted);
margin-bottom: 4px;
text-align: center;
}
.wo-legend-bar {
height: 10px;
border-radius: 3px;
width: 100%;
}
.wo-legend-ticks {
display: flex;
justify-content: space-between;
font-size: 8px;
color: var(--muted);
margin-top: 2px;
}
@media (max-width: 920px) { @media (max-width: 920px) {
.app { .app {
grid-template-columns: 1fr; grid-template-columns: 1fr;

파일 보기

@ -0,0 +1,32 @@
import type { AisTargetSearchResponse } from '../model/types';
export async function searchChnprmship(
params: { minutes: number },
signal?: AbortSignal,
): Promise<AisTargetSearchResponse> {
const base = (import.meta.env.VITE_API_URL || '/snp-api').replace(/\/$/, '');
const u = new URL(`${base}/api/ais-target/chnprmship`, window.location.origin);
u.searchParams.set('minutes', String(params.minutes));
const res = await fetch(u, { signal, headers: { accept: 'application/json' } });
const txt = await res.text();
let json: unknown = null;
try {
json = JSON.parse(txt);
} catch {
// ignore
}
if (!res.ok) {
const msg =
json && typeof json === 'object' && typeof (json as { message?: unknown }).message === 'string'
? (json as { message: string }).message
: txt.slice(0, 200) || res.statusText;
throw new Error(`chnprmship API failed: ${res.status} ${msg}`);
}
if (!json || typeof json !== 'object') throw new Error('chnprmship API returned invalid payload');
const parsed = json as AisTargetSearchResponse;
if (!parsed.success) throw new Error(parsed.message || 'chnprmship API returned success=false');
return parsed;
}

파일 보기

@ -0,0 +1,32 @@
import type { TrackResponse } from '../model/types';
const API_BASE = (import.meta.env.VITE_API_URL || '/snp-api').replace(/\/$/, '');
export async function fetchVesselTrack(
mmsi: number,
minutes: number,
signal?: AbortSignal,
): Promise<TrackResponse> {
const controller = new AbortController();
const timeout = setTimeout(() => controller.abort(), 15_000);
const combinedSignal = signal ?? controller.signal;
try {
const url = `${API_BASE}/api/ais-target/${mmsi}/track?minutes=${minutes}`;
const res = await fetch(url, {
signal: combinedSignal,
headers: { accept: 'application/json' },
});
if (!res.ok) {
const text = await res.text().catch(() => '');
throw new Error(`Track API error ${res.status}: ${text.slice(0, 200)}`);
}
const json = (await res.json()) as TrackResponse;
return json;
} finally {
clearTimeout(timeout);
}
}

파일 보기

@ -0,0 +1,115 @@
import { haversineNm } from '../../../shared/lib/geo/haversineNm';
import type { ActiveTrack, NormalizedTrip } from '../model/types';
/** 시간순 정렬 후 TripsLayer용 정규화 데이터 생성 */
export function normalizeTrip(
track: ActiveTrack,
color: [number, number, number],
): NormalizedTrip {
const sorted = [...track.points].sort(
(a, b) => new Date(a.messageTimestamp).getTime() - new Date(b.messageTimestamp).getTime(),
);
if (sorted.length === 0) {
return { path: [], timestamps: [], mmsi: track.mmsi, name: '', color };
}
const baseEpoch = new Date(sorted[0].messageTimestamp).getTime();
const path: [number, number][] = [];
const timestamps: number[] = [];
for (const pt of sorted) {
path.push([pt.lon, pt.lat]);
// 32-bit float 정밀도를 보장하기 위해 첫 포인트 기준 초 단위 오프셋
timestamps.push((new Date(pt.messageTimestamp).getTime() - baseEpoch) / 1000);
}
return {
path,
timestamps,
mmsi: track.mmsi,
name: sorted[0].name || `MMSI ${track.mmsi}`,
color,
};
}
/** Globe 전용 — LineString GeoJSON */
export function buildTrackLineGeoJson(
track: ActiveTrack,
): GeoJSON.FeatureCollection<GeoJSON.LineString> {
const sorted = [...track.points].sort(
(a, b) => new Date(a.messageTimestamp).getTime() - new Date(b.messageTimestamp).getTime(),
);
if (sorted.length < 2) {
return { type: 'FeatureCollection', features: [] };
}
let totalDistanceNm = 0;
const coordinates: [number, number][] = [];
for (let i = 0; i < sorted.length; i++) {
const pt = sorted[i];
coordinates.push([pt.lon, pt.lat]);
if (i > 0) {
const prev = sorted[i - 1];
totalDistanceNm += haversineNm(prev.lat, prev.lon, pt.lat, pt.lon);
}
}
return {
type: 'FeatureCollection',
features: [
{
type: 'Feature',
properties: {
mmsi: track.mmsi,
name: sorted[0].name || `MMSI ${track.mmsi}`,
pointCount: sorted.length,
minutes: track.minutes,
totalDistanceNm: Math.round(totalDistanceNm * 100) / 100,
},
geometry: { type: 'LineString', coordinates },
},
],
};
}
/** Globe+Mercator 공용 — Point GeoJSON */
export function buildTrackPointsGeoJson(
track: ActiveTrack,
): GeoJSON.FeatureCollection<GeoJSON.Point> {
const sorted = [...track.points].sort(
(a, b) => new Date(a.messageTimestamp).getTime() - new Date(b.messageTimestamp).getTime(),
);
return {
type: 'FeatureCollection',
features: sorted.map((pt, index) => ({
type: 'Feature' as const,
properties: {
mmsi: pt.mmsi,
name: pt.name,
sog: pt.sog,
cog: pt.cog,
heading: pt.heading,
status: pt.status,
messageTimestamp: pt.messageTimestamp,
index,
},
geometry: { type: 'Point' as const, coordinates: [pt.lon, pt.lat] },
})),
};
}
export function getTrackTimeRange(trip: NormalizedTrip): {
minTime: number;
maxTime: number;
durationSec: number;
} {
if (trip.timestamps.length === 0) {
return { minTime: 0, maxTime: 0, durationSec: 0 };
}
const minTime = trip.timestamps[0];
const maxTime = trip.timestamps[trip.timestamps.length - 1];
return { minTime, maxTime, durationSec: maxTime - minTime };
}

파일 보기

@ -0,0 +1,39 @@
export interface TrackPoint {
mmsi: number;
name: string;
lat: number;
lon: number;
heading: number;
sog: number;
cog: number;
rot: number;
length: number;
width: number;
draught: number;
status: string;
messageTimestamp: string;
receivedDate: string;
source: string;
}
export interface TrackResponse {
success: boolean;
message: string;
data: TrackPoint[];
}
export interface ActiveTrack {
mmsi: number;
minutes: number;
points: TrackPoint[];
fetchedAt: number;
}
/** TripsLayer용 정규화 데이터 */
export interface NormalizedTrip {
path: [number, number][];
timestamps: number[];
mmsi: number;
name: string;
color: [number, number, number];
}

파일 보기

@ -0,0 +1,107 @@
import type { WeatherQueryPoint, WeatherPoint, WeatherSnapshot } from '../model/types';
const MARINE_BASE = 'https://marine-api.open-meteo.com/v1/marine';
const WEATHER_BASE = 'https://api.open-meteo.com/v1/forecast';
const MARINE_PARAMS = 'current=wave_height,wave_direction,wave_period,swell_wave_height,swell_wave_direction,sea_surface_temperature';
const WEATHER_PARAMS = 'current=wind_speed_10m,wind_direction_10m,wind_gusts_10m,temperature_2m,weather_code';
const TIMEOUT_MS = 10_000;
/* Open-Meteo 다중 좌표 응답 타입 */
interface MarineCurrentItem {
wave_height?: number;
wave_direction?: number;
wave_period?: number;
swell_wave_height?: number;
swell_wave_direction?: number;
sea_surface_temperature?: number;
}
interface WeatherCurrentItem {
wind_speed_10m?: number;
wind_direction_10m?: number;
wind_gusts_10m?: number;
temperature_2m?: number;
weather_code?: number;
}
/* 단일 좌표 응답 */
interface SingleMarineResponse {
current?: MarineCurrentItem;
}
/* 다중 좌표 응답 — 배열 */
type MarineResponse = SingleMarineResponse | SingleMarineResponse[];
interface SingleWeatherResponse {
current?: WeatherCurrentItem;
}
type WeatherResponse = SingleWeatherResponse | SingleWeatherResponse[];
function buildMultiCoordParams(points: WeatherQueryPoint[]): string {
const lats = points.map((p) => p.lat.toFixed(2)).join(',');
const lons = points.map((p) => p.lon.toFixed(2)).join(',');
return `latitude=${lats}&longitude=${lons}`;
}
function n(v: number | undefined): number | null {
return v != null && Number.isFinite(v) ? v : null;
}
/**
* Open-Meteo Marine + Weather API를 .
* centroid .
*/
export async function fetchWeatherForPoints(
points: WeatherQueryPoint[],
): Promise<WeatherSnapshot> {
if (points.length === 0) {
return { points: [], fetchedAt: Date.now() };
}
const coords = buildMultiCoordParams(points);
const ac = new AbortController();
const timer = setTimeout(() => ac.abort(), TIMEOUT_MS);
try {
const [marineRaw, weatherRaw] = await Promise.all([
fetch(`${MARINE_BASE}?${coords}&${MARINE_PARAMS}`, { signal: ac.signal })
.then((r) => r.json() as Promise<MarineResponse>),
fetch(`${WEATHER_BASE}?${coords}&${WEATHER_PARAMS}`, { signal: ac.signal })
.then((r) => r.json() as Promise<WeatherResponse>),
]);
// 단일 좌표면 배열이 아닌 단일 객체가 반환됨 → 통일
const marines: SingleMarineResponse[] = Array.isArray(marineRaw) ? marineRaw : [marineRaw];
const weathers: SingleWeatherResponse[] = Array.isArray(weatherRaw) ? weatherRaw : [weatherRaw];
const result: WeatherPoint[] = points.map((pt, i) => {
const mc = marines[i]?.current;
const wc = weathers[i]?.current;
return {
label: pt.label,
color: pt.color,
lat: pt.lat,
lon: pt.lon,
zoneId: pt.zoneId,
waveHeight: n(mc?.wave_height),
waveDirection: n(mc?.wave_direction),
wavePeriod: n(mc?.wave_period),
swellHeight: n(mc?.swell_wave_height),
swellDirection: n(mc?.swell_wave_direction),
seaSurfaceTemp: n(mc?.sea_surface_temperature),
windSpeed: n(wc?.wind_speed_10m),
windDirection: n(wc?.wind_direction_10m),
windGusts: n(wc?.wind_gusts_10m),
temperature: n(wc?.temperature_2m),
weatherCode: n(wc?.weather_code),
};
});
return { points: result, fetchedAt: Date.now() };
} finally {
clearTimeout(timer);
}
}

파일 보기

@ -0,0 +1,78 @@
/** 기상 데이터 유틸리티 */
const DIRECTION_LABELS = ['N', 'NE', 'E', 'SE', 'S', 'SW', 'W', 'NW'] as const;
const DIRECTION_ARROWS = ['↓', '↙', '←', '↖', '↑', '↗', '→', '↘'] as const;
/**
* 8 .
* "바람이 불어오는 방향" 0° = (N).
*/
export function getWindDirectionLabel(deg: number | null): string {
if (deg == null) return '-';
const idx = Math.round(((deg % 360) + 360) % 360 / 45) % 8;
return DIRECTION_LABELS[idx];
}
/**
* .
* ( ).
*/
export function getWindArrow(deg: number | null): string {
if (deg == null) return '';
const idx = Math.round(((deg % 360) + 360) % 360 / 45) % 8;
return DIRECTION_ARROWS[idx];
}
export type WaveSeverity = 'calm' | 'moderate' | 'rough' | 'severe';
/** 파고 등급 분류 */
export function getWaveSeverity(m: number | null): WaveSeverity {
if (m == null || m < 0.5) return 'calm';
if (m < 1.5) return 'moderate';
if (m < 2.5) return 'rough';
return 'severe';
}
/**
* WMO Weather Interpretation Code .
* https://open-meteo.com/en/docs 참조.
*/
export function getWeatherLabel(code: number | null): string {
if (code == null) return '-';
if (code === 0) return '맑음';
if (code <= 3) return ['약간 흐림', '흐림', '매우 흐림'][code - 1];
if (code <= 49) return '안개';
if (code <= 59) return '이슬비';
if (code <= 69) return '비';
if (code <= 79) return '눈';
if (code <= 84) return '소나기';
if (code <= 94) return '뇌우';
if (code <= 99) return '뇌우(우박)';
return '-';
}
/**
* MultiPolygon centroid .
* GeoJSON MultiPolygon: number[][][][]
*/
export function computeMultiPolygonCentroid(
coordinates: number[][][][],
): [number, number] {
let sumLon = 0;
let sumLat = 0;
let count = 0;
for (const polygon of coordinates) {
// 외곽 링만 사용 (polygon[0])
const ring = polygon[0];
if (!ring) continue;
for (const coord of ring) {
sumLon += coord[0];
sumLat += coord[1];
count++;
}
}
if (count === 0) return [0, 0];
return [sumLon / count, sumLat / count];
}

파일 보기

@ -0,0 +1,40 @@
import type { ZoneId } from '../../zone/model/meta';
/** 기상 조회 대상 지점 (입력용) */
export interface WeatherQueryPoint {
label: string;
color: string;
lat: number;
lon: number;
zoneId?: ZoneId;
}
/** 단일 지점의 기상 데이터 */
export interface WeatherPoint {
label: string;
color: string;
lat: number;
lon: number;
zoneId?: ZoneId;
/* Marine */
waveHeight: number | null;
waveDirection: number | null;
wavePeriod: number | null;
swellHeight: number | null;
swellDirection: number | null;
seaSurfaceTemp: number | null;
/* Weather */
windSpeed: number | null;
windDirection: number | null;
windGusts: number | null;
temperature: number | null;
weatherCode: number | null;
}
/** 기상 스냅샷 (전체 지점 묶음) */
export interface WeatherSnapshot {
points: WeatherPoint[];
fetchedAt: number;
}

파일 보기

@ -1,5 +1,6 @@
import { useEffect, useMemo, useRef, useState } from "react"; import { useEffect, useMemo, useRef, useState } from "react";
import { searchAisTargets } from "../../entities/aisTarget/api/searchAisTargets"; import { searchAisTargets } from "../../entities/aisTarget/api/searchAisTargets";
import { searchChnprmship } from "../../entities/aisTarget/api/searchChnprmship";
import type { AisTarget } from "../../entities/aisTarget/model/types"; import type { AisTarget } from "../../entities/aisTarget/model/types";
export type AisPollingStatus = "idle" | "loading" | "ready" | "error"; export type AisPollingStatus = "idle" | "loading" | "ready" | "error";
@ -17,14 +18,21 @@ export type AisPollingSnapshot = {
}; };
export type AisPollingOptions = { export type AisPollingOptions = {
initialMinutes?: number; /** 초기 chnprmship API 호출 시 minutes (기본 120) */
bootstrapMinutes?: number; chnprmshipMinutes?: number;
/** 주기적 폴링 시 search API minutes (기본 2) */
incrementalMinutes?: number; incrementalMinutes?: number;
/** 폴링 주기 ms (기본 60_000) */
intervalMs?: number; intervalMs?: number;
/** 보존 기간 (기본 chnprmshipMinutes) */
retentionMinutes?: number; retentionMinutes?: number;
/** incremental 폴링 시 bbox 필터 */
bbox?: string; bbox?: string;
/** incremental 폴링 시 중심 경도 */
centerLon?: number; centerLon?: number;
/** incremental 폴링 시 중심 위도 */
centerLat?: number; centerLat?: number;
/** incremental 폴링 시 반경(m) */
radiusMeters?: number; radiusMeters?: number;
enabled?: boolean; enabled?: boolean;
}; };
@ -112,11 +120,10 @@ function pruneStore(store: Map<number, AisTarget>, retentionMinutes: number, bbo
} }
export function useAisTargetPolling(opts: AisPollingOptions = {}) { export function useAisTargetPolling(opts: AisPollingOptions = {}) {
const initialMinutes = opts.initialMinutes ?? 60; const chnprmshipMinutes = opts.chnprmshipMinutes ?? 120;
const bootstrapMinutes = opts.bootstrapMinutes ?? initialMinutes; const incrementalMinutes = opts.incrementalMinutes ?? 2;
const incrementalMinutes = opts.incrementalMinutes ?? 1;
const intervalMs = opts.intervalMs ?? 60_000; const intervalMs = opts.intervalMs ?? 60_000;
const retentionMinutes = opts.retentionMinutes ?? initialMinutes; const retentionMinutes = opts.retentionMinutes ?? chnprmshipMinutes;
const enabled = opts.enabled ?? true; const enabled = opts.enabled ?? true;
const bbox = opts.bbox; const bbox = opts.bbox;
const centerLon = opts.centerLon; const centerLon = opts.centerLon;
@ -146,31 +153,17 @@ export function useAisTargetPolling(opts: AisPollingOptions = {}) {
const controller = new AbortController(); const controller = new AbortController();
const generation = ++generationRef.current; const generation = ++generationRef.current;
async function run(minutes: number, context: "bootstrap" | "initial" | "incremental") { function applyResult(res: { data: AisTarget[]; message: string }, minutes: number) {
try {
setSnapshot((s) => ({ ...s, status: s.status === "idle" ? "loading" : s.status, error: null }));
const res = await searchAisTargets(
{
minutes,
bbox,
centerLon,
centerLat,
radiusMeters,
},
controller.signal,
);
if (cancelled || generation !== generationRef.current) return; if (cancelled || generation !== generationRef.current) return;
const { inserted, upserted } = upsertByMmsi(storeRef.current, res.data); const { inserted, upserted } = upsertByMmsi(storeRef.current, res.data);
const deleted = pruneStore(storeRef.current, retentionMinutes, bbox); const deleted = pruneStore(storeRef.current, retentionMinutes, bbox);
const total = storeRef.current.size; const total = storeRef.current.size;
const lastFetchAt = new Date().toISOString();
setSnapshot({ setSnapshot({
status: "ready", status: "ready",
error: null, error: null,
lastFetchAt, lastFetchAt: new Date().toISOString(),
lastFetchMinutes: minutes, lastFetchMinutes: minutes,
lastMessage: res.message, lastMessage: res.message,
total, total,
@ -179,17 +172,41 @@ export function useAisTargetPolling(opts: AisPollingOptions = {}) {
lastDeleted: deleted, lastDeleted: deleted,
}); });
setRev((r) => r + 1); setRev((r) => r + 1);
}
async function runInitial(minutes: number) {
try {
setSnapshot((s) => ({ ...s, status: "loading", error: null }));
const res = await searchChnprmship({ minutes }, controller.signal);
applyResult(res, minutes);
} catch (e) { } catch (e) {
if (cancelled || generation !== generationRef.current) return; if (cancelled || generation !== generationRef.current) return;
setSnapshot((s) => ({ setSnapshot((s) => ({
...s, ...s,
status: context === "incremental" ? s.status : "error", status: "error",
error: e instanceof Error ? e.message : String(e), error: e instanceof Error ? e.message : String(e),
})); }));
} }
} }
// Reset store when polling config changes (bbox, retention, etc). async function runIncremental(minutes: number) {
try {
setSnapshot((s) => ({ ...s, error: null }));
const res = await searchAisTargets(
{ minutes, bbox, centerLon, centerLat, radiusMeters },
controller.signal,
);
applyResult(res, minutes);
} catch (e) {
if (cancelled || generation !== generationRef.current) return;
setSnapshot((s) => ({
...s,
error: e instanceof Error ? e.message : String(e),
}));
}
}
// Reset store when polling config changes.
storeRef.current = new Map(); storeRef.current = new Map();
setSnapshot({ setSnapshot({
status: "loading", status: "loading",
@ -204,12 +221,11 @@ export function useAisTargetPolling(opts: AisPollingOptions = {}) {
}); });
setRev((r) => r + 1); setRev((r) => r + 1);
void run(bootstrapMinutes, "bootstrap"); // 초기 로드: chnprmship API 1회 호출
if (bootstrapMinutes !== initialMinutes) { void runInitial(chnprmshipMinutes);
void run(initialMinutes, "initial");
}
const id = window.setInterval(() => void run(incrementalMinutes, "incremental"), intervalMs); // 주기적 폴링: search API로 incremental 업데이트
const id = window.setInterval(() => void runIncremental(incrementalMinutes), intervalMs);
return () => { return () => {
cancelled = true; cancelled = true;
@ -217,8 +233,7 @@ export function useAisTargetPolling(opts: AisPollingOptions = {}) {
window.clearInterval(id); window.clearInterval(id);
}; };
}, [ }, [
initialMinutes, chnprmshipMinutes,
bootstrapMinutes,
incrementalMinutes, incrementalMinutes,
intervalMs, intervalMs,
retentionMinutes, retentionMinutes,

파일 보기

@ -0,0 +1,240 @@
import type { LiveShipFeature, ViewportBounds } from '../model/liveShip.types';
interface RenderConfig {
defaultMinInterval: number;
maxInterval: number;
targetRenderTime: number;
maxRenderTime: number;
}
interface DensityConfig {
maxZoom: number;
maxPerCell: number;
gridSizeMultiplier: number;
}
type RenderCallback = (ships: LiveShipFeature[], trigger: number) => void;
const RENDER_CONFIG: RenderConfig = {
defaultMinInterval: 1000,
maxInterval: 5000,
targetRenderTime: 100,
maxRenderTime: 500,
};
const ZOOM_MIN_INTERVAL: Record<number, number> = {
7: 4000,
8: 3500,
9: 3000,
10: 2500,
11: 2000,
12: 1500,
13: 1500,
14: 1000,
};
const DENSITY_LIMITS: DensityConfig[] = [
{ maxZoom: 5, maxPerCell: 20, gridSizeMultiplier: 120 },
{ maxZoom: 6, maxPerCell: 25, gridSizeMultiplier: 100 },
{ maxZoom: 7, maxPerCell: 33, gridSizeMultiplier: 80 },
{ maxZoom: 8, maxPerCell: 35, gridSizeMultiplier: 75 },
{ maxZoom: 9, maxPerCell: 38, gridSizeMultiplier: 70 },
{ maxZoom: 10, maxPerCell: 40, gridSizeMultiplier: 55 },
{ maxZoom: 11, maxPerCell: 43, gridSizeMultiplier: 40 },
{ maxZoom: Number.POSITIVE_INFINITY, maxPerCell: Number.POSITIVE_INFINITY, gridSizeMultiplier: 30 },
];
const SHIP_KIND_PRIORITY: Record<string, number> = {
'000021': 2,
'000025': 3,
'000022': 4,
'000024': 6,
'000023': 7,
'000020': 8,
'000027': 9,
'000028': 10,
};
function getShipPriority(ship: LiveShipFeature): number {
return SHIP_KIND_PRIORITY[ship.signalKindCode] ?? 11;
}
function getDensityConfig(zoomLevel: number): DensityConfig {
for (const config of DENSITY_LIMITS) {
if (zoomLevel <= config.maxZoom) return config;
}
return DENSITY_LIMITS[DENSITY_LIMITS.length - 1];
}
function getMinIntervalByZoom(zoom: number): number {
const zoomInt = Math.floor(zoom);
if (zoomInt <= 7) return ZOOM_MIN_INTERVAL[7];
if (zoomInt >= 14) return ZOOM_MIN_INTERVAL[14];
return ZOOM_MIN_INTERVAL[zoomInt] || RENDER_CONFIG.defaultMinInterval;
}
function isInViewport(ship: LiveShipFeature, bounds: ViewportBounds): boolean {
if (ship.latitude < bounds.minLat || ship.latitude > bounds.maxLat) return false;
if (bounds.minLon <= bounds.maxLon) {
return ship.longitude >= bounds.minLon && ship.longitude <= bounds.maxLon;
}
return ship.longitude >= bounds.minLon || ship.longitude <= bounds.maxLon;
}
function applyDensityLimit(ships: LiveShipFeature[], zoomLevel: number): LiveShipFeature[] {
const config = getDensityConfig(zoomLevel);
if (config.maxPerCell === Number.POSITIVE_INFINITY) return ships;
const sorted = [...ships].sort((a, b) => getShipPriority(a) - getShipPriority(b));
const gridSize = Math.pow(2, -zoomLevel) * config.gridSizeMultiplier;
const gridCounts = new Map<string, number>();
const result: LiveShipFeature[] = [];
for (const ship of sorted) {
const gridX = Math.floor(ship.longitude / gridSize);
const gridY = Math.floor(ship.latitude / gridSize);
const key = `${gridX},${gridY}`;
const count = gridCounts.get(key) || 0;
if (count < config.maxPerCell) {
result.push(ship);
gridCounts.set(key, count + 1);
}
}
return result.reverse();
}
class ShipBatchRenderer {
private data: LiveShipFeature[] = [];
private callback: RenderCallback | null = null;
private viewportBounds: ViewportBounds | null = null;
private currentZoom = 10;
private pendingRender = false;
private isRendering = false;
private animationHandle: ReturnType<typeof setTimeout> | number | null = null;
private lastRenderTime = 0;
private currentInterval = RENDER_CONFIG.defaultMinInterval;
private renderTrigger = 0;
private lastRenderedShips: LiveShipFeature[] = [];
initialize(callback: RenderCallback): void {
this.callback = callback;
}
setData(data: LiveShipFeature[]): void {
this.data = data;
this.requestRender();
}
setViewportBounds(bounds: ViewportBounds | null): void {
this.viewportBounds = bounds;
}
setZoom(zoom: number): boolean {
const prevInt = Math.floor(this.currentZoom);
const nextInt = Math.floor(zoom);
this.currentZoom = zoom;
const nextMin = getMinIntervalByZoom(zoom);
if (nextMin > this.currentInterval) {
this.currentInterval = nextMin;
}
return prevInt !== nextInt;
}
requestRender(): void {
this.pendingRender = true;
if (this.animationHandle != null) return;
this.scheduleRender();
}
immediateRender(): void {
if (this.animationHandle != null) {
clearTimeout(this.animationHandle as ReturnType<typeof setTimeout>);
cancelAnimationFrame(this.animationHandle as number);
this.animationHandle = null;
}
this.pendingRender = false;
requestAnimationFrame(() => this.executeRender());
}
getRenderedShips(): LiveShipFeature[] {
return this.lastRenderedShips;
}
dispose(): void {
if (this.animationHandle != null) {
clearTimeout(this.animationHandle as ReturnType<typeof setTimeout>);
cancelAnimationFrame(this.animationHandle as number);
this.animationHandle = null;
}
this.pendingRender = false;
this.callback = null;
this.lastRenderedShips = [];
}
private scheduleRender(): void {
const elapsed = Date.now() - this.lastRenderTime;
const delay = Math.max(0, this.currentInterval - elapsed);
this.animationHandle = setTimeout(() => {
this.animationHandle = requestAnimationFrame(() => this.executeRender());
}, delay);
}
private executeRender(): void {
if (this.isRendering) {
this.animationHandle = null;
return;
}
if (!this.callback) {
this.animationHandle = null;
return;
}
const startTime = performance.now();
this.isRendering = true;
try {
let ships = this.data;
if (this.viewportBounds) {
ships = ships.filter((ship) => isInViewport(ship, this.viewportBounds as ViewportBounds));
}
const densityLimited = applyDensityLimit(ships, this.currentZoom);
this.renderTrigger += 1;
this.lastRenderedShips = densityLimited;
this.callback(densityLimited, this.renderTrigger);
const renderTime = performance.now() - startTime;
this.adjustRenderInterval(renderTime);
} finally {
this.isRendering = false;
this.lastRenderTime = Date.now();
this.animationHandle = null;
if (this.pendingRender) {
this.pendingRender = false;
this.scheduleRender();
}
}
}
private adjustRenderInterval(renderTime: number): void {
const minInterval = getMinIntervalByZoom(this.currentZoom);
if (renderTime > RENDER_CONFIG.maxRenderTime) {
this.currentInterval = Math.min(this.currentInterval * 1.2, RENDER_CONFIG.maxInterval);
return;
}
if (renderTime < RENDER_CONFIG.targetRenderTime) {
this.currentInterval = Math.max(this.currentInterval * 0.9, minInterval);
}
}
}
export { applyDensityLimit, getDensityConfig, getMinIntervalByZoom };
export type { RenderCallback };
export default ShipBatchRenderer;

파일 보기

@ -0,0 +1,11 @@
import { useMemo } from 'react';
import type { AisTarget } from '../../../entities/aisTarget/model/types';
import type { LegacyVesselInfo } from '../../../entities/legacyVessel/model/types';
import { toLiveShipFeatures } from '../lib/adapters';
export function useLiveShipAdapter(
targets: AisTarget[],
legacyHits?: Map<number, LegacyVesselInfo> | null,
) {
return useMemo(() => toLiveShipFeatures(targets, legacyHits), [targets, legacyHits]);
}

파일 보기

@ -0,0 +1,88 @@
import { useEffect, useMemo, useRef, useState, type MutableRefObject } from 'react';
import type maplibregl from 'maplibre-gl';
import type { AisTarget } from '../../../entities/aisTarget/model/types';
import ShipBatchRenderer from '../core/ShipBatchRenderer';
import type { LiveShipFeature, ViewportBounds } from '../model/liveShip.types';
interface UseLiveShipBatchRenderResult {
renderedFeatures: LiveShipFeature[];
renderedTargets: AisTarget[];
renderedMmsiSet: Set<number>;
}
function getMapBounds(map: maplibregl.Map): ViewportBounds {
const bounds = map.getBounds();
return {
minLon: bounds.getWest(),
maxLon: bounds.getEast(),
minLat: bounds.getSouth(),
maxLat: bounds.getNorth(),
};
}
export function useLiveShipBatchRender(
mapRef: MutableRefObject<maplibregl.Map | null>,
features: LiveShipFeature[],
sourceTargets: AisTarget[],
mapSyncEpoch: number,
): UseLiveShipBatchRenderResult {
const rendererRef = useRef<ShipBatchRenderer | null>(null);
const [renderedFeatures, setRenderedFeatures] = useState<LiveShipFeature[]>([]);
useEffect(() => {
const renderer = new ShipBatchRenderer();
renderer.initialize((ships) => {
setRenderedFeatures(ships);
});
rendererRef.current = renderer;
return () => {
renderer.dispose();
rendererRef.current = null;
};
}, []);
useEffect(() => {
const renderer = rendererRef.current;
if (!renderer) return;
renderer.setData(features);
renderer.immediateRender();
}, [features]);
useEffect(() => {
const map = mapRef.current;
const renderer = rendererRef.current;
if (!map || !renderer) return;
const sync = () => {
if (!rendererRef.current) return;
renderer.setZoom(map.getZoom());
renderer.setViewportBounds(getMapBounds(map));
renderer.requestRender();
};
sync();
map.on('moveend', sync);
map.on('zoomend', sync);
return () => {
map.off('moveend', sync);
map.off('zoomend', sync);
};
}, [mapRef, mapSyncEpoch]);
const renderedMmsiSet = useMemo(() => {
const next = new Set<number>();
for (const feature of renderedFeatures) {
next.add(feature.mmsi);
}
return next;
}, [renderedFeatures]);
const renderedTargets = useMemo(() => {
if (renderedMmsiSet.size === 0) return [];
return sourceTargets.filter((target) => renderedMmsiSet.has(target.mmsi));
}, [sourceTargets, renderedMmsiSet]);
return { renderedFeatures, renderedTargets, renderedMmsiSet };
}

파일 보기

@ -0,0 +1,63 @@
import type { AisTarget } from '../../../entities/aisTarget/model/types';
import type { LegacyVesselInfo } from '../../../entities/legacyVessel/model/types';
import { SIGNAL_KIND, SIGNAL_SOURCE_AIS, type LiveShipFeature, type SignalKindCode } from '../model/liveShip.types';
function mapVesselTypeToSignalKind(vesselType: string | undefined): SignalKindCode {
if (!vesselType) return SIGNAL_KIND.NORMAL;
const vt = vesselType.toLowerCase();
if (vt.includes('fishing')) return SIGNAL_KIND.FISHING;
if (vt.includes('passenger')) return SIGNAL_KIND.PASSENGER;
if (vt.includes('cargo')) return SIGNAL_KIND.CARGO;
if (vt.includes('tanker')) return SIGNAL_KIND.TANKER;
if (vt.includes('military') || vt.includes('law') || vt.includes('government')) return SIGNAL_KIND.GOV;
if (vt.includes('buoy')) return SIGNAL_KIND.BUOY;
return SIGNAL_KIND.NORMAL;
}
function mapLegacyShipCodeToSignalKind(shipCode: string | undefined): SignalKindCode {
if (!shipCode) return SIGNAL_KIND.NORMAL;
if (shipCode === 'FC') return SIGNAL_KIND.CARGO;
return SIGNAL_KIND.FISHING;
}
export function toLiveShipFeature(target: AisTarget, legacy: LegacyVesselInfo | undefined | null): LiveShipFeature {
const targetId = String(target.mmsi);
const signalKindCode = legacy
? mapLegacyShipCodeToSignalKind(legacy.shipCode)
: mapVesselTypeToSignalKind(target.vesselType);
return {
mmsi: target.mmsi,
featureId: `${SIGNAL_SOURCE_AIS}${targetId}`,
targetId,
originalTargetId: targetId,
signalSourceCode: SIGNAL_SOURCE_AIS,
signalKindCode,
shipName: (target.name || '').trim(),
longitude: target.lon,
latitude: target.lat,
sog: Number.isFinite(target.sog) ? target.sog : 0,
cog: Number.isFinite(target.cog) ? target.cog : 0,
heading: Number.isFinite(target.heading) ? target.heading : 0,
messageTimestamp: target.messageTimestamp || target.receivedDate || new Date().toISOString(),
nationalCode: legacy ? 'CN' : '',
vesselType: target.vesselType,
raw: target,
};
}
export function toLiveShipFeatures(
targets: AisTarget[],
legacyHits?: Map<number, LegacyVesselInfo> | null,
): LiveShipFeature[] {
const out: LiveShipFeature[] = [];
for (const target of targets) {
if (!target) continue;
if (!Number.isFinite(target.mmsi)) continue;
if (!Number.isFinite(target.lon) || !Number.isFinite(target.lat)) continue;
out.push(toLiveShipFeature(target, legacyHits?.get(target.mmsi) ?? null));
}
return out;
}

파일 보기

@ -0,0 +1,42 @@
import type { AisTarget } from '../../../entities/aisTarget/model/types';
export const SIGNAL_SOURCE_AIS = '000001';
export const SIGNAL_KIND = {
FISHING: '000020',
KCGV: '000021',
PASSENGER: '000022',
CARGO: '000023',
TANKER: '000024',
GOV: '000025',
NORMAL: '000027',
BUOY: '000028',
} as const;
export type SignalKindCode = (typeof SIGNAL_KIND)[keyof typeof SIGNAL_KIND] | string;
export interface LiveShipFeature {
mmsi: number;
featureId: string;
targetId: string;
originalTargetId: string;
signalSourceCode: string;
signalKindCode: SignalKindCode;
shipName: string;
longitude: number;
latitude: number;
sog: number;
cog: number;
heading: number;
messageTimestamp: string;
nationalCode: string;
vesselType?: string;
raw?: AisTarget;
}
export interface ViewportBounds {
minLon: number;
maxLon: number;
minLat: number;
maxLat: number;
}

파일 보기

@ -0,0 +1,125 @@
import { useMemo } from 'react';
import { getCurrentPositions } from '../lib/interpolate';
import { createReplayTrailLayer } from '../layers/replayLayers';
import { createDynamicTrackLayers, createStaticTrackLayers } from '../layers/trackLayers';
import type { CurrentVesselPosition, ProcessedTrack } from '../model/track.types';
import { useTrackPlaybackStore } from '../stores/trackPlaybackStore';
import { useTrackQueryStore } from '../stores/trackQueryStore';
export interface TrackReplayDeckRenderState {
trackReplayDeckLayers: unknown[];
enabledTracks: ProcessedTrack[];
currentPositions: CurrentVesselPosition[];
showPoints: boolean;
showVirtualShip: boolean;
showLabels: boolean;
renderEpoch: number;
}
export function useTrackReplayDeckLayers(): TrackReplayDeckRenderState {
const tracks = useTrackQueryStore((state) => state.tracks);
const disabledVesselIds = useTrackQueryStore((state) => state.disabledVesselIds);
const highlightedVesselId = useTrackQueryStore((state) => state.highlightedVesselId);
const setHighlightedVesselId = useTrackQueryStore((state) => state.setHighlightedVesselId);
const showPoints = useTrackQueryStore((state) => state.showPoints);
const showVirtualShip = useTrackQueryStore((state) => state.showVirtualShip);
const showLabels = useTrackQueryStore((state) => state.showLabels);
const showTrail = useTrackQueryStore((state) => state.showTrail);
const renderEpoch = useTrackQueryStore((state) => state.renderEpoch);
const isPlaying = useTrackPlaybackStore((state) => state.isPlaying);
const currentTime = useTrackPlaybackStore((state) => state.currentTime);
const playbackRenderTime = useMemo(() => {
if (!isPlaying) return currentTime;
// Throttle to ~10fps while playing to reduce relayout pressure.
return Math.floor(currentTime / 100) * 100;
}, [isPlaying, currentTime]);
const enabledTracks = useMemo(() => {
if (!tracks.length) return [];
if (disabledVesselIds.size === 0) return tracks;
return tracks.filter((track) => !disabledVesselIds.has(track.vesselId));
}, [tracks, disabledVesselIds]);
const currentPositions = useMemo(() => {
void renderEpoch;
if (enabledTracks.length === 0) return [];
const sampled = getCurrentPositions(enabledTracks, playbackRenderTime);
if (sampled.length > 0 || isPlaying) return sampled;
// Ensure an immediate first-frame marker when query data arrives but
// playback has not started yet (globe static-render case).
return enabledTracks.flatMap((track) => {
if (track.geometry.length === 0) return [];
const firstTs = track.timestampsMs[0] ?? playbackRenderTime;
return [
{
vesselId: track.vesselId,
targetId: track.targetId,
sigSrcCd: track.sigSrcCd,
shipName: track.shipName,
shipKindCode: track.shipKindCode,
nationalCode: track.nationalCode,
position: track.geometry[0],
heading: 0,
speed: track.speeds[0] ?? 0,
timestamp: firstTs,
} as CurrentVesselPosition,
];
});
}, [enabledTracks, playbackRenderTime, isPlaying, renderEpoch]);
const staticLayers = useMemo(
() => {
void renderEpoch;
return createStaticTrackLayers({
tracks: enabledTracks,
showPoints,
highlightedVesselId,
onPathHover: setHighlightedVesselId,
});
},
[enabledTracks, showPoints, highlightedVesselId, setHighlightedVesselId, renderEpoch],
);
const dynamicLayers = useMemo(
() => {
void renderEpoch;
return createDynamicTrackLayers({
currentPositions,
showVirtualShip,
showLabels,
onPathHover: setHighlightedVesselId,
});
},
[currentPositions, showVirtualShip, showLabels, setHighlightedVesselId, renderEpoch],
);
const trailLayer = useMemo(
() => {
void renderEpoch;
return createReplayTrailLayer({
tracks: enabledTracks,
currentTime: playbackRenderTime,
showTrail,
});
},
[enabledTracks, playbackRenderTime, showTrail, renderEpoch],
);
const trackReplayDeckLayers = useMemo(
() => [...staticLayers, ...(trailLayer ? [trailLayer] : []), ...dynamicLayers],
[staticLayers, dynamicLayers, trailLayer],
);
return {
trackReplayDeckLayers,
enabledTracks,
currentPositions,
showPoints,
showVirtualShip,
showLabels,
renderEpoch,
};
}

파일 보기

@ -0,0 +1,60 @@
import { TripsLayer } from '@deck.gl/geo-layers';
import type { Layer } from '@deck.gl/core';
import type { ProcessedTrack } from '../model/track.types';
import { getShipKindColor } from '../lib/adapters';
import { TRACK_REPLAY_LAYER_IDS } from './trackLayers';
import { DEPTH_DISABLED_PARAMS } from '../../../widgets/map3d/constants';
interface ReplayTrip {
vesselId: string;
path: [number, number][];
timestamps: number[];
color: [number, number, number, number];
}
function toReplayTrips(tracks: ProcessedTrack[]): ReplayTrip[] {
const out: ReplayTrip[] = [];
for (const track of tracks) {
if (!track.geometry.length || !track.timestampsMs.length) continue;
const baseTime = track.timestampsMs[0];
out.push({
vesselId: track.vesselId,
path: track.geometry,
timestamps: track.timestampsMs.map((ts) => ts - baseTime),
color: getShipKindColor(track.shipKindCode),
});
}
return out;
}
export function createReplayTrailLayer(options: {
tracks: ProcessedTrack[];
currentTime: number;
showTrail: boolean;
}): Layer | null {
const { tracks, currentTime, showTrail } = options;
if (!showTrail || tracks.length === 0) return null;
const trips = toReplayTrips(tracks);
if (trips.length === 0) return null;
const minBaseTime = Math.min(...tracks.map((track) => track.timestampsMs[0] || 0));
const relativeCurrentTime = Math.max(0, currentTime - minBaseTime);
return new TripsLayer<ReplayTrip>({
id: TRACK_REPLAY_LAYER_IDS.TRAIL,
data: trips,
getPath: (d) => d.path,
getTimestamps: (d) => d.timestamps,
getColor: (d) => d.color,
currentTime: relativeCurrentTime,
trailLength: 1000 * 60 * 60,
fadeTrail: true,
widthMinPixels: 2,
widthMaxPixels: 4,
capRounded: true,
jointRounded: true,
parameters: DEPTH_DISABLED_PARAMS,
pickable: false,
});
}

파일 보기

@ -0,0 +1,200 @@
import { IconLayer, PathLayer, ScatterplotLayer, TextLayer } from '@deck.gl/layers';
import type { Layer, PickingInfo } from '@deck.gl/core';
import { DEPTH_DISABLED_PARAMS, SHIP_ICON_MAPPING } from '../../../widgets/map3d/constants';
import { getCachedShipIcon } from '../../../widgets/map3d/lib/shipIconCache';
import { getShipKindColor } from '../lib/adapters';
import type { CurrentVesselPosition, ProcessedTrack } from '../model/track.types';
export const TRACK_REPLAY_LAYER_IDS = {
PATH: 'track-replay-path',
POINTS: 'track-replay-points',
VIRTUAL_SHIP: 'track-replay-virtual-ship',
VIRTUAL_LABEL: 'track-replay-virtual-label',
TRAIL: 'track-replay-trail',
} as const;
interface PathData {
vesselId: string;
path: [number, number][];
color: [number, number, number, number];
}
interface PointData {
vesselId: string;
position: [number, number];
color: [number, number, number, number];
timestamp: number;
speed: number;
index: number;
}
const MAX_POINTS_PER_TRACK = 800;
export function createStaticTrackLayers(options: {
tracks: ProcessedTrack[];
showPoints: boolean;
highlightedVesselId?: string | null;
onPathHover?: (vesselId: string | null) => void;
}): Layer[] {
const { tracks, showPoints, highlightedVesselId, onPathHover } = options;
const layers: Layer[] = [];
if (!tracks || tracks.length === 0) return layers;
const pathData: PathData[] = tracks.map((track) => ({
vesselId: track.vesselId,
path: track.geometry,
color: getShipKindColor(track.shipKindCode),
}));
layers.push(
new PathLayer<PathData>({
id: TRACK_REPLAY_LAYER_IDS.PATH,
data: pathData,
getPath: (d) => d.path,
getColor: (d) =>
highlightedVesselId && highlightedVesselId === d.vesselId
? [255, 255, 0, 255]
: [d.color[0], d.color[1], d.color[2], 235],
getWidth: (d) => (highlightedVesselId && highlightedVesselId === d.vesselId ? 5 : 3),
widthUnits: 'pixels',
widthMinPixels: 1,
widthMaxPixels: 6,
parameters: DEPTH_DISABLED_PARAMS,
jointRounded: true,
capRounded: true,
pickable: true,
onHover: (info: PickingInfo<PathData>) => {
onPathHover?.(info.object?.vesselId ?? null);
},
updateTriggers: {
getColor: [highlightedVesselId],
getWidth: [highlightedVesselId],
},
}),
);
if (showPoints) {
const pointData: PointData[] = [];
for (const track of tracks) {
const color = getShipKindColor(track.shipKindCode);
const len = track.geometry.length;
if (len <= MAX_POINTS_PER_TRACK) {
for (let i = 0; i < len; i++) {
pointData.push({
vesselId: track.vesselId,
position: track.geometry[i],
color,
timestamp: track.timestampsMs[i] || 0,
speed: track.speeds[i] || 0,
index: i,
});
}
} else {
const step = len / MAX_POINTS_PER_TRACK;
for (let i = 0; i < MAX_POINTS_PER_TRACK; i++) {
const idx = Math.min(Math.floor(i * step), len - 1);
pointData.push({
vesselId: track.vesselId,
position: track.geometry[idx],
color,
timestamp: track.timestampsMs[idx] || 0,
speed: track.speeds[idx] || 0,
index: idx,
});
}
}
}
layers.push(
new ScatterplotLayer<PointData>({
id: TRACK_REPLAY_LAYER_IDS.POINTS,
data: pointData,
getPosition: (d) => d.position,
getFillColor: (d) => d.color,
getRadius: 3,
radiusUnits: 'pixels',
radiusMinPixels: 2,
radiusMaxPixels: 5,
parameters: DEPTH_DISABLED_PARAMS,
pickable: false,
}),
);
}
return layers;
}
export function createDynamicTrackLayers(options: {
currentPositions: CurrentVesselPosition[];
showVirtualShip: boolean;
showLabels: boolean;
onIconHover?: (position: CurrentVesselPosition | null, x: number, y: number) => void;
onPathHover?: (vesselId: string | null) => void;
}): Layer[] {
const { currentPositions, showVirtualShip, showLabels, onIconHover, onPathHover } = options;
const layers: Layer[] = [];
if (!currentPositions || currentPositions.length === 0) return layers;
if (showVirtualShip) {
layers.push(
new IconLayer<CurrentVesselPosition>({
id: TRACK_REPLAY_LAYER_IDS.VIRTUAL_SHIP,
data: currentPositions,
iconAtlas: getCachedShipIcon(),
iconMapping: SHIP_ICON_MAPPING,
getIcon: () => 'ship',
getPosition: (d) => d.position,
getSize: 22,
sizeUnits: 'pixels',
getAngle: (d) => -d.heading,
getColor: (d) => {
const base = getShipKindColor(d.shipKindCode);
return [base[0], base[1], base[2], 245] as [number, number, number, number];
},
parameters: DEPTH_DISABLED_PARAMS,
pickable: true,
onHover: (info: PickingInfo<CurrentVesselPosition>) => {
if (info.object) {
onPathHover?.(info.object.vesselId);
onIconHover?.(info.object, info.x, info.y);
} else {
onPathHover?.(null);
onIconHover?.(null, 0, 0);
}
},
}),
);
}
if (showLabels) {
const labelData = currentPositions.filter((position) => (position.shipName || '').trim().length > 0);
if (labelData.length > 0) {
layers.push(
new TextLayer<CurrentVesselPosition>({
id: TRACK_REPLAY_LAYER_IDS.VIRTUAL_LABEL,
data: labelData,
getPosition: (d) => d.position,
getText: (d) => d.shipName,
getColor: [226, 232, 240, 240],
getSize: 11,
getTextAnchor: 'start',
getAlignmentBaseline: 'center',
getPixelOffset: [14, 0],
fontFamily: 'Malgun Gothic, Arial, sans-serif',
outlineColor: [2, 6, 23, 220],
outlineWidth: 2,
parameters: DEPTH_DISABLED_PARAMS,
pickable: false,
}),
);
}
}
return layers;
}
export function isTrackReplayLayerId(id: unknown): boolean {
return typeof id === 'string' && id.startsWith('track-replay-');
}

파일 보기

@ -0,0 +1,159 @@
import type { TrackPoint } from '../../../entities/vesselTrack/model/types';
import type { ProcessedTrack, TrackStats } from '../model/track.types';
const DEFAULT_SHIP_KIND = '000027';
const DEFAULT_SIGNAL_SOURCE = '000001';
const EPSILON_DISTANCE = 1e-10;
function toFiniteNumber(value: unknown): number | null {
if (typeof value === 'number') return Number.isFinite(value) ? value : null;
if (typeof value === 'string') {
const parsed = Number(value.trim());
return Number.isFinite(parsed) ? parsed : null;
}
return null;
}
export function normalizeTrackTimestampMs(value: string | number | undefined | null): number {
if (typeof value === 'number') {
return value < 1e12 ? value * 1000 : value;
}
if (typeof value === 'string' && value.trim().length > 0) {
if (/^\d{10,}$/.test(value)) {
const asNum = Number(value);
return asNum < 1e12 ? asNum * 1000 : asNum;
}
const parsed = new Date(value).getTime();
if (Number.isFinite(parsed)) return parsed;
}
return Date.now();
}
function calculateStats(points: TrackPoint[]): TrackStats {
let maxSpeed = 0;
let speedSum = 0;
for (const point of points) {
const speed = Number.isFinite(point.sog) ? point.sog : 0;
maxSpeed = Math.max(maxSpeed, speed);
speedSum += speed;
}
return {
totalDistanceNm: 0,
avgSpeed: points.length > 0 ? speedSum / points.length : 0,
maxSpeed,
pointCount: points.length,
};
}
export function convertLegacyTrackPointsToProcessedTrack(
mmsi: number,
points: TrackPoint[],
hints?: {
shipName?: string;
shipKindCode?: string;
nationalCode?: string;
sigSrcCd?: string;
},
): ProcessedTrack | null {
const sorted = [...points].sort(
(a, b) => normalizeTrackTimestampMs(a.messageTimestamp) - normalizeTrackTimestampMs(b.messageTimestamp),
);
if (sorted.length === 0) return null;
const first = sorted[0];
const normalizedPoints = sorted
.map((point) => {
const lon = toFiniteNumber(point.lon);
const lat = toFiniteNumber(point.lat);
if (lon == null || lat == null) return null;
const ts = normalizeTrackTimestampMs(point.messageTimestamp);
const speed = toFiniteNumber(point.sog) ?? 0;
return {
point,
lon,
lat,
ts,
speed,
};
})
.filter((entry): entry is NonNullable<typeof entry> => entry != null);
if (normalizedPoints.length === 0) return null;
const geometry: [number, number][] = [];
const timestampsMs: number[] = [];
const speeds: number[] = [];
const statsPoints: TrackPoint[] = [];
for (const entry of normalizedPoints) {
const lastCoord = geometry[geometry.length - 1];
const isDuplicateCoord =
lastCoord != null &&
Math.abs(lastCoord[0] - entry.lon) <= EPSILON_DISTANCE &&
Math.abs(lastCoord[1] - entry.lat) <= EPSILON_DISTANCE;
const lastTs = timestampsMs[timestampsMs.length - 1];
// Drop exact duplicate samples to avoid zero-length/duplicate segments.
if (isDuplicateCoord && lastTs === entry.ts) continue;
geometry.push([entry.lon, entry.lat]);
timestampsMs.push(entry.ts);
speeds.push(entry.speed);
statsPoints.push(entry.point);
}
if (geometry.length === 0) return null;
const stats = calculateStats(statsPoints);
return {
vesselId: `${hints?.sigSrcCd || DEFAULT_SIGNAL_SOURCE}_${mmsi}`,
targetId: String(mmsi),
sigSrcCd: hints?.sigSrcCd || DEFAULT_SIGNAL_SOURCE,
shipName: (hints?.shipName || first.name || '').trim() || `MMSI ${mmsi}`,
shipKindCode: hints?.shipKindCode || DEFAULT_SHIP_KIND,
nationalCode: hints?.nationalCode || '',
geometry,
timestampsMs,
speeds,
stats,
};
}
export function getTracksTimeRange(tracks: ProcessedTrack[]): { start: number; end: number } | null {
if (tracks.length === 0) return null;
let min = Number.POSITIVE_INFINITY;
let max = Number.NEGATIVE_INFINITY;
for (const track of tracks) {
if (track.timestampsMs.length === 0) continue;
min = Math.min(min, track.timestampsMs[0]);
max = Math.max(max, track.timestampsMs[track.timestampsMs.length - 1]);
}
if (!Number.isFinite(min) || !Number.isFinite(max) || min > max) return null;
return { start: min, end: max };
}
export function getShipKindColor(shipKindCode: string): [number, number, number, number] {
const colors: Record<string, [number, number, number, number]> = {
'000020': [25, 116, 25, 180],
'000021': [0, 41, 255, 180],
'000022': [176, 42, 42, 180],
'000023': [255, 139, 54, 180],
'000024': [255, 0, 0, 180],
'000025': [92, 30, 224, 180],
'000027': [255, 135, 207, 180],
'000028': [232, 95, 27, 180],
};
return colors[shipKindCode] || colors['000027'];
}

파일 보기

@ -0,0 +1,91 @@
import type { CurrentVesselPosition, ProcessedTrack } from '../model/track.types';
function calculateHeading(from: [number, number], to: [number, number]): number {
const dx = to[0] - from[0];
const dy = to[1] - from[1];
let angle = (Math.atan2(dx, dy) * 180) / Math.PI;
if (angle < 0) angle += 360;
return angle;
}
function interpolate(
from: [number, number],
to: [number, number],
fromTs: number,
toTs: number,
currentTs: number,
): [number, number] {
if (toTs <= fromTs) return from;
if (currentTs <= fromTs) return from;
if (currentTs >= toTs) return to;
const ratio = (currentTs - fromTs) / (toTs - fromTs);
return [
from[0] + (to[0] - from[0]) * ratio,
from[1] + (to[1] - from[1]) * ratio,
];
}
export function getCurrentPosition(track: ProcessedTrack, currentTime: number): CurrentVesselPosition | null {
const len = track.timestampsMs.length;
if (len === 0 || track.geometry.length === 0) return null;
const firstTime = track.timestampsMs[0];
const lastTime = track.timestampsMs[len - 1];
if (currentTime < firstTime || currentTime > lastTime) return null;
if (len === 1) {
return {
vesselId: track.vesselId,
targetId: track.targetId,
sigSrcCd: track.sigSrcCd,
shipName: track.shipName,
shipKindCode: track.shipKindCode,
nationalCode: track.nationalCode,
position: track.geometry[0],
heading: 0,
speed: track.speeds[0] || 0,
timestamp: firstTime,
};
}
let hi = len - 1;
let lo = 0;
while (lo <= hi) {
const mid = Math.floor((lo + hi) / 2);
if (track.timestampsMs[mid] <= currentTime) lo = mid + 1;
else hi = mid - 1;
}
const idx = Math.max(0, Math.min(len - 2, hi));
const from = track.geometry[idx];
const to = track.geometry[idx + 1];
const fromTs = track.timestampsMs[idx];
const toTs = track.timestampsMs[idx + 1];
const position = interpolate(from, to, fromTs, toTs, currentTime);
const heading = calculateHeading(from, to);
const speed = track.speeds[idx] || 0;
return {
vesselId: track.vesselId,
targetId: track.targetId,
sigSrcCd: track.sigSrcCd,
shipName: track.shipName,
shipKindCode: track.shipKindCode,
nationalCode: track.nationalCode,
position,
heading,
speed,
timestamp: currentTime,
};
}
export function getCurrentPositions(tracks: ProcessedTrack[], currentTime: number): CurrentVesselPosition[] {
const out: CurrentVesselPosition[] = [];
for (const track of tracks) {
const pos = getCurrentPosition(track, currentTime);
if (pos) out.push(pos);
}
return out;
}

파일 보기

@ -0,0 +1,45 @@
export type LonLat = [number, number];
export interface TrackStats {
totalDistanceNm: number;
avgSpeed: number;
maxSpeed: number;
pointCount: number;
}
export interface ProcessedTrack {
vesselId: string;
targetId: string;
sigSrcCd: string;
shipName: string;
shipKindCode: string;
nationalCode: string;
geometry: LonLat[];
timestampsMs: number[];
speeds: number[];
stats: TrackStats;
}
export interface CurrentVesselPosition {
vesselId: string;
targetId: string;
sigSrcCd: string;
shipName: string;
shipKindCode: string;
nationalCode: string;
position: LonLat;
heading: number;
speed: number;
timestamp: number;
}
export interface TrackQueryRequest {
mmsi: number;
minutes: number;
}
export interface ReplayStreamQueryRequest {
startTime: string;
endTime: string;
vessels: Array<{ sigSrcCd: string; targetId: string }>;
}

파일 보기

@ -0,0 +1,35 @@
import type { ReplayStreamQueryRequest } from '../model/track.types';
export interface ReplayStreamHandlers {
onConnected?: () => void;
onDisconnected?: () => void;
onError?: (error: Error) => void;
onChunk?: (chunk: unknown) => void;
onCompleted?: () => void;
}
class ReplayStreamService {
private readonly enabled = String(import.meta.env.VITE_TRACKING_WS_ENABLED || 'false') === 'true';
async connect(handlers?: ReplayStreamHandlers): Promise<boolean> {
void handlers;
if (!this.enabled) return false;
return false;
}
async startQuery(request: ReplayStreamQueryRequest): Promise<boolean> {
void request;
if (!this.enabled) return false;
return false;
}
async cancel(): Promise<void> {
if (!this.enabled) return;
}
disconnect(): void {
if (!this.enabled) return;
}
}
export const replayStreamService = new ReplayStreamService();

파일 보기

@ -0,0 +1,162 @@
import { fetchVesselTrack } from '../../../entities/vesselTrack/api/fetchTrack';
import { convertLegacyTrackPointsToProcessedTrack } from '../lib/adapters';
import type { ProcessedTrack } from '../model/track.types';
type QueryTrackByMmsiParams = {
mmsi: number;
minutes: number;
shipNameHint?: string;
shipKindCodeHint?: string;
nationalCodeHint?: string;
};
type V2TrackResponse = {
vesselId?: string;
targetId?: string;
sigSrcCd?: string;
shipName?: string;
shipKindCode?: string;
nationalCode?: string;
geometry?: [number, number][];
timestamps?: Array<string | number>;
speeds?: number[];
totalDistance?: number;
avgSpeed?: number;
maxSpeed?: number;
pointCount?: number;
};
function normalizeTimestampMs(value: string | number): number {
if (typeof value === 'number') return value < 1e12 ? value * 1000 : value;
if (/^\d{10,}$/.test(value)) {
const asNum = Number(value);
return asNum < 1e12 ? asNum * 1000 : asNum;
}
const parsed = new Date(value).getTime();
return Number.isFinite(parsed) ? parsed : 0;
}
function toFiniteNumber(value: unknown): number | null {
if (typeof value === 'number') return Number.isFinite(value) ? value : null;
if (typeof value === 'string') {
const parsed = Number(value.trim());
return Number.isFinite(parsed) ? parsed : null;
}
return null;
}
function convertV2Tracks(rows: V2TrackResponse[]): ProcessedTrack[] {
const out: ProcessedTrack[] = [];
for (const row of rows) {
if (!row.geometry || row.geometry.length === 0) continue;
const timestamps = Array.isArray(row.timestamps) ? row.timestamps : [];
const timestampsMs = timestamps.map((ts) => normalizeTimestampMs(ts));
const sortedIndices = timestampsMs
.map((_, idx) => idx)
.sort((a, b) => timestampsMs[a] - timestampsMs[b]);
const geometry: [number, number][] = [];
const sortedTimes: number[] = [];
const speeds: number[] = [];
for (const idx of sortedIndices) {
const coord = row.geometry?.[idx];
if (!Array.isArray(coord) || coord.length !== 2) continue;
const nLon = toFiniteNumber(coord[0]);
const nLat = toFiniteNumber(coord[1]);
if (nLon == null || nLat == null) continue;
geometry.push([nLon, nLat]);
sortedTimes.push(timestampsMs[idx]);
speeds.push(toFiniteNumber(row.speeds?.[idx]) ?? 0);
}
if (geometry.length === 0) continue;
const targetId = row.targetId || row.vesselId || '';
const sigSrcCd = row.sigSrcCd || '000001';
out.push({
vesselId: row.vesselId || `${sigSrcCd}_${targetId}`,
targetId,
sigSrcCd,
shipName: (row.shipName || '').trim() || targetId,
shipKindCode: row.shipKindCode || '000027',
nationalCode: row.nationalCode || '',
geometry,
timestampsMs: sortedTimes,
speeds,
stats: {
totalDistanceNm: row.totalDistance || 0,
avgSpeed: row.avgSpeed || 0,
maxSpeed: row.maxSpeed || 0,
pointCount: row.pointCount || geometry.length,
},
});
}
return out;
}
async function queryLegacyTrack(params: QueryTrackByMmsiParams): Promise<ProcessedTrack[]> {
const response = await fetchVesselTrack(params.mmsi, params.minutes);
if (!response.success || response.data.length === 0) return [];
const converted = convertLegacyTrackPointsToProcessedTrack(params.mmsi, response.data, {
shipName: params.shipNameHint,
shipKindCode: params.shipKindCodeHint,
nationalCode: params.nationalCodeHint,
});
return converted ? [converted] : [];
}
async function queryV2Track(params: QueryTrackByMmsiParams): Promise<ProcessedTrack[]> {
const base = (import.meta.env.VITE_TRACK_V2_BASE_URL || '').trim();
if (!base) {
return queryLegacyTrack(params);
}
const end = new Date();
const start = new Date(end.getTime() - params.minutes * 60_000);
const requestBody = {
startTime: start.toISOString().slice(0, 19),
endTime: end.toISOString().slice(0, 19),
vessels: [{ sigSrcCd: '000001', targetId: String(params.mmsi) }],
isIntegration: '0',
};
const endpoint = `${base.replace(/\/$/, '')}/api/v2/tracks/vessels`;
const res = await fetch(endpoint, {
method: 'POST',
headers: { 'Content-Type': 'application/json', accept: 'application/json' },
body: JSON.stringify(requestBody),
});
if (!res.ok) {
return queryLegacyTrack(params);
}
const json = (await res.json()) as unknown;
const rows = Array.isArray(json)
? (json as V2TrackResponse[])
: Array.isArray((json as { data?: unknown }).data)
? ((json as { data: V2TrackResponse[] }).data)
: [];
const converted = convertV2Tracks(rows);
if (converted.length > 0) return converted;
return queryLegacyTrack(params);
}
export async function queryTrackByMmsi(params: QueryTrackByMmsiParams): Promise<ProcessedTrack[]> {
const mode = String(import.meta.env.VITE_TRACK_SOURCE_MODE || 'legacy').toLowerCase();
if (mode === 'v2') {
return queryV2Track(params);
}
return queryLegacyTrack(params);
}

파일 보기

@ -0,0 +1,162 @@
import { create } from 'zustand';
interface TrackPlaybackState {
isPlaying: boolean;
currentTime: number;
startTime: number;
endTime: number;
playbackSpeed: number;
loop: boolean;
loopStart: number;
loopEnd: number;
play: () => void;
pause: () => void;
stop: () => void;
setCurrentTime: (time: number) => void;
setPlaybackSpeed: (speed: number) => void;
toggleLoop: () => void;
setLoopSection: (start: number, end: number) => void;
setTimeRange: (start: number, end: number) => void;
syncToRangeStart: () => void;
reset: () => void;
}
let animationFrameId: number | null = null;
let lastFrameTime: number | null = null;
function clearAnimation(): void {
if (animationFrameId != null) {
cancelAnimationFrame(animationFrameId);
animationFrameId = null;
}
lastFrameTime = null;
}
export const useTrackPlaybackStore = create<TrackPlaybackState>()((set, get) => {
const animate = (): void => {
const state = get();
if (!state.isPlaying) return;
const now = performance.now();
if (lastFrameTime == null) {
lastFrameTime = now;
}
const delta = now - lastFrameTime;
lastFrameTime = now;
const advanceMs = delta * state.playbackSpeed;
let nextTime = state.currentTime + advanceMs;
const rangeStart = state.loop ? state.loopStart : state.startTime;
const rangeEnd = state.loop ? state.loopEnd : state.endTime;
if (nextTime >= rangeEnd) {
if (state.loop) {
nextTime = rangeStart;
} else {
nextTime = state.endTime;
set({ currentTime: nextTime, isPlaying: false });
clearAnimation();
return;
}
}
set({ currentTime: nextTime });
animationFrameId = requestAnimationFrame(animate);
};
return {
isPlaying: false,
currentTime: 0,
startTime: 0,
endTime: 0,
playbackSpeed: 100,
loop: false,
loopStart: 0,
loopEnd: 0,
play: () => {
const state = get();
if (state.endTime <= state.startTime) return;
if (state.currentTime < state.startTime || state.currentTime > state.endTime) {
set({ currentTime: state.startTime });
}
set({ isPlaying: true });
clearAnimation();
animationFrameId = requestAnimationFrame(animate);
},
pause: () => {
clearAnimation();
set({ isPlaying: false });
},
stop: () => {
clearAnimation();
set((state) => ({ isPlaying: false, currentTime: state.startTime }));
},
setCurrentTime: (time: number) => {
const { startTime, endTime } = get();
const clamped = Math.max(startTime, Math.min(endTime, time));
set({ currentTime: clamped });
},
setPlaybackSpeed: (speed: number) => {
const normalized = Number.isFinite(speed) && speed > 0 ? speed : 1;
set({ playbackSpeed: normalized });
},
toggleLoop: () => {
set((state) => ({ loop: !state.loop }));
},
setLoopSection: (start: number, end: number) => {
const state = get();
const clampedStart = Math.max(state.startTime, Math.min(end, start));
const clampedEnd = Math.min(state.endTime, Math.max(start, end));
set({ loopStart: clampedStart, loopEnd: clampedEnd });
},
setTimeRange: (start: number, end: number) => {
const safeStart = Number.isFinite(start) ? start : 0;
const safeEnd = Number.isFinite(end) ? end : safeStart;
clearAnimation();
set({
isPlaying: false,
startTime: safeStart,
endTime: safeEnd,
currentTime: safeStart,
loopStart: safeStart,
loopEnd: safeEnd,
});
},
syncToRangeStart: () => {
clearAnimation();
set((state) => ({
isPlaying: false,
currentTime: state.startTime,
}));
},
reset: () => {
clearAnimation();
set({
isPlaying: false,
currentTime: 0,
startTime: 0,
endTime: 0,
playbackSpeed: 100,
loop: false,
loopStart: 0,
loopEnd: 0,
});
},
};
});
export const TRACK_PLAYBACK_SPEED_OPTIONS = [1, 5, 10, 25, 50, 100] as const;

파일 보기

@ -0,0 +1,210 @@
import { create } from 'zustand';
import { getTracksTimeRange } from '../lib/adapters';
import type { ProcessedTrack } from '../model/track.types';
import { useTrackPlaybackStore } from './trackPlaybackStore';
export type TrackQueryStatus = 'idle' | 'loading' | 'ready' | 'error';
interface TrackQueryState {
tracks: ProcessedTrack[];
disabledVesselIds: Set<string>;
highlightedVesselId: string | null;
isLoading: boolean;
error: string | null;
queryState: TrackQueryStatus;
renderEpoch: number;
lastQueryKey: string | null;
showPoints: boolean;
showVirtualShip: boolean;
showLabels: boolean;
showTrail: boolean;
hideLiveShips: boolean;
beginQuery: (queryKey: string) => void;
applyTracksSuccess: (tracks: ProcessedTrack[], queryKey?: string | null) => void;
applyQueryError: (error: string, queryKey?: string | null) => void;
closeQuery: () => void;
setTracks: (tracks: ProcessedTrack[]) => void;
clearTracks: () => void;
setLoading: (loading: boolean) => void;
setError: (error: string | null) => void;
setHighlightedVesselId: (vesselId: string | null) => void;
setShowPoints: (show: boolean) => void;
setShowVirtualShip: (show: boolean) => void;
setShowLabels: (show: boolean) => void;
setShowTrail: (show: boolean) => void;
setHideLiveShips: (hide: boolean) => void;
toggleVesselEnabled: (vesselId: string) => void;
getEnabledTracks: () => ProcessedTrack[];
reset: () => void;
}
export const useTrackQueryStore = create<TrackQueryState>()((set, get) => ({
tracks: [],
disabledVesselIds: new Set<string>(),
highlightedVesselId: null,
isLoading: false,
error: null,
queryState: 'idle',
renderEpoch: 0,
lastQueryKey: null,
showPoints: true,
showVirtualShip: true,
showLabels: true,
showTrail: true,
hideLiveShips: false,
beginQuery: (queryKey: string) => {
useTrackPlaybackStore.getState().reset();
set((state) => ({
tracks: [],
disabledVesselIds: new Set<string>(),
highlightedVesselId: null,
isLoading: true,
error: null,
queryState: 'loading',
renderEpoch: state.renderEpoch + 1,
lastQueryKey: queryKey,
hideLiveShips: false,
}));
},
applyTracksSuccess: (tracks: ProcessedTrack[], queryKey?: string | null) => {
const currentQueryKey = get().lastQueryKey;
if (queryKey != null && queryKey !== currentQueryKey) {
// Ignore stale async responses from an older query.
return;
}
const range = getTracksTimeRange(tracks);
const playback = useTrackPlaybackStore.getState();
if (range) {
playback.setTimeRange(range.start, range.end);
playback.syncToRangeStart();
playback.setPlaybackSpeed(100);
} else {
playback.reset();
}
set((state) => ({
tracks,
disabledVesselIds: new Set<string>(),
highlightedVesselId: null,
isLoading: false,
error: null,
queryState: 'ready',
renderEpoch: state.renderEpoch + 1,
lastQueryKey: queryKey ?? state.lastQueryKey,
}));
if (range) {
if (typeof window !== 'undefined') {
window.requestAnimationFrame(() => {
useTrackPlaybackStore.getState().play();
});
} else {
useTrackPlaybackStore.getState().play();
}
}
},
applyQueryError: (error: string, queryKey?: string | null) => {
const currentQueryKey = get().lastQueryKey;
if (queryKey != null && queryKey !== currentQueryKey) {
// Ignore stale async errors from an older query.
return;
}
useTrackPlaybackStore.getState().reset();
set((state) => ({
tracks: [],
disabledVesselIds: new Set<string>(),
highlightedVesselId: null,
isLoading: false,
error,
queryState: 'error',
renderEpoch: state.renderEpoch + 1,
lastQueryKey: queryKey ?? state.lastQueryKey,
hideLiveShips: false,
}));
},
closeQuery: () => {
useTrackPlaybackStore.getState().reset();
set((state) => ({
tracks: [],
disabledVesselIds: new Set<string>(),
highlightedVesselId: null,
isLoading: false,
error: null,
queryState: 'idle',
renderEpoch: state.renderEpoch + 1,
lastQueryKey: null,
hideLiveShips: false,
}));
},
setTracks: (tracks: ProcessedTrack[]) => {
get().applyTracksSuccess(tracks, get().lastQueryKey);
},
clearTracks: () => {
get().closeQuery();
},
setLoading: (loading: boolean) =>
set((state) => ({
isLoading: loading,
queryState: loading ? 'loading' : state.error ? 'error' : state.tracks.length > 0 ? 'ready' : 'idle',
})),
setError: (error: string | null) =>
set((state) => ({
error,
queryState: error ? 'error' : state.isLoading ? 'loading' : state.tracks.length > 0 ? 'ready' : 'idle',
})),
setHighlightedVesselId: (vesselId: string | null) => set({ highlightedVesselId: vesselId }),
setShowPoints: (show: boolean) => set({ showPoints: show }),
setShowVirtualShip: (show: boolean) => set({ showVirtualShip: show }),
setShowLabels: (show: boolean) => set({ showLabels: show }),
setShowTrail: (show: boolean) => set({ showTrail: show }),
setHideLiveShips: (hide: boolean) => set({ hideLiveShips: hide }),
toggleVesselEnabled: (vesselId: string) => {
const next = new Set(get().disabledVesselIds);
if (next.has(vesselId)) next.delete(vesselId);
else next.add(vesselId);
set((state) => ({
disabledVesselIds: next,
renderEpoch: state.renderEpoch + 1,
}));
},
getEnabledTracks: () => {
const { tracks, disabledVesselIds } = get();
if (disabledVesselIds.size === 0) return tracks;
return tracks.filter((track) => !disabledVesselIds.has(track.vesselId));
},
reset: () => {
useTrackPlaybackStore.getState().reset();
set((state) => ({
tracks: [],
disabledVesselIds: new Set<string>(),
highlightedVesselId: null,
isLoading: false,
error: null,
queryState: 'idle',
renderEpoch: state.renderEpoch + 1,
lastQueryKey: null,
showPoints: true,
showVirtualShip: true,
showLabels: true,
showTrail: true,
hideLiveShips: false,
}));
},
}));

파일 보기

@ -0,0 +1,415 @@
import { useCallback, useEffect, useRef, useState } from 'react';
import maplibregl from 'maplibre-gl';
import { config as maptilerConfig } from '@maptiler/sdk';
import {
WindLayer,
TemperatureLayer,
PrecipitationLayer,
PressureLayer,
RadarLayer,
ColorRamp,
} from '@maptiler/weather';
import { getMapTilerKey } from '../../widgets/map3d/lib/mapCore';
/** 6종 기상 레이어 ID */
export type WeatherLayerId =
| 'wind'
| 'temperature'
| 'precipitation'
| 'pressure'
| 'radar'
| 'clouds';
export interface WeatherLayerMeta {
id: WeatherLayerId;
label: string;
icon: string;
}
export const WEATHER_LAYERS: WeatherLayerMeta[] = [
{ id: 'wind', label: '바람', icon: '💨' },
{ id: 'temperature', label: '기온', icon: '🌡' },
{ id: 'precipitation', label: '강수', icon: '🌧' },
{ id: 'pressure', label: '기압', icon: '◎' },
{ id: 'radar', label: '레이더', icon: '📡' },
{ id: 'clouds', label: '구름', icon: '☁' },
];
const LAYER_ID_PREFIX = 'maptiler-weather-';
/** 한중일 + 남중국해 영역 [west, south, east, north] */
const TILE_BOUNDS: [number, number, number, number] = [100, 10, 150, 50];
type AnyWeatherLayer = WindLayer | TemperatureLayer | PrecipitationLayer | PressureLayer | RadarLayer;
const DEFAULT_ENABLED: Record<WeatherLayerId, boolean> = {
wind: false,
temperature: false,
precipitation: false,
pressure: false,
radar: false,
clouds: false,
};
/** 각 레이어별 범례 정보 */
export interface LegendInfo {
label: string;
unit: string;
colorRamp: ColorRamp;
}
export const LEGEND_META: Record<WeatherLayerId, LegendInfo> = {
wind: { label: '풍속', unit: 'm/s', colorRamp: ColorRamp.builtin.WIND_ROCKET },
temperature: { label: '기온', unit: '°C', colorRamp: ColorRamp.builtin.TEMPERATURE_3 },
precipitation: { label: '강수량', unit: 'mm/h', colorRamp: ColorRamp.builtin.PRECIPITATION },
pressure: { label: '기압', unit: 'hPa', colorRamp: ColorRamp.builtin.PRESSURE_2 },
radar: { label: '레이더', unit: 'dBZ', colorRamp: ColorRamp.builtin.RADAR },
clouds: { label: '구름', unit: 'dBZ', colorRamp: ColorRamp.builtin.RADAR_CLOUD },
};
/**
* .
* animateByFactor(value) 1 value초 .
* 3600 = 1/, 7200 = 2/ ...
*/
export const SPEED_OPTIONS = [
{ value: 1800, label: '30분/초' },
{ value: 3600, label: '1시간/초' },
{ value: 7200, label: '2시간/초' },
{ value: 14400, label: '4시간/초' },
];
// bounds는 TileLayerOptions에 정의되나 개별 레이어 생성자 타입에 누락되어 as any 필요
/* eslint-disable @typescript-eslint/no-explicit-any */
function createLayerInstance(layerId: WeatherLayerId, opacity: number): AnyWeatherLayer {
const id = `${LAYER_ID_PREFIX}${layerId}`;
const opts = { id, opacity, bounds: TILE_BOUNDS };
switch (layerId) {
case 'wind':
return new WindLayer({
...opts,
colorramp: ColorRamp.builtin.WIND_ROCKET,
speed: 0.001,
fadeFactor: 0.03,
maxAmount: 256,
density: 4,
fastColor: [255, 100, 80, 230],
} as any);
case 'temperature':
return new TemperatureLayer({
...opts,
colorramp: ColorRamp.builtin.TEMPERATURE_3,
} as any);
case 'precipitation':
return new PrecipitationLayer({
...opts,
colorramp: ColorRamp.builtin.PRECIPITATION,
} as any);
case 'pressure':
return new PressureLayer({
...opts,
colorramp: ColorRamp.builtin.PRESSURE_2,
} as any);
case 'radar':
return new RadarLayer({
...opts,
colorramp: ColorRamp.builtin.RADAR,
} as any);
case 'clouds':
return new RadarLayer({
...opts,
colorramp: ColorRamp.builtin.RADAR_CLOUD,
} as any);
}
}
/* eslint-enable @typescript-eslint/no-explicit-any */
/** 타임라인 step 간격 (3시간 = 10 800초) */
const STEP_INTERVAL_SEC = 3 * 3600;
/** start~end 를 STEP_INTERVAL_SEC 단위로 나눈 epoch-초 배열 */
function buildSteps(startSec: number, endSec: number): number[] {
const steps: number[] = [];
for (let t = startSec; t <= endSec; t += STEP_INTERVAL_SEC) {
steps.push(t);
}
// 마지막 step이 endSec 와 다르면 보정
if (steps.length > 0 && steps[steps.length - 1] < endSec) {
steps.push(endSec);
}
return steps;
}
export interface WeatherOverlayState {
enabled: Record<WeatherLayerId, boolean>;
activeLayerId: WeatherLayerId | null;
opacity: number;
isPlaying: boolean;
animationSpeed: number;
currentTime: Date | null;
startTime: Date | null;
endTime: Date | null;
/** step epoch(초) 배열 — 타임라인 눈금 */
steps: number[];
isReady: boolean;
}
export interface WeatherOverlayActions {
toggleLayer: (id: WeatherLayerId) => void;
setOpacity: (v: number) => void;
play: () => void;
pause: () => void;
setSpeed: (factor: number) => void;
/** epoch 초 단위로 seek (SDK 내부 시간 = 초) */
seekTo: (epochSec: number) => void;
}
/**
* MapTiler Weather SDK 6 .
* map null이면 , / .
*/
export function useWeatherOverlay(
map: maplibregl.Map | null,
): WeatherOverlayState & WeatherOverlayActions {
const [enabled, setEnabled] = useState<Record<WeatherLayerId, boolean>>({ ...DEFAULT_ENABLED });
const [opacity, setOpacityState] = useState(0.6);
const [isPlaying, setIsPlaying] = useState(false);
const [animationSpeed, setAnimationSpeed] = useState(3600);
const [currentTime, setCurrentTime] = useState<Date | null>(null);
const [startTime, setStartTime] = useState<Date | null>(null);
const [endTime, setEndTime] = useState<Date | null>(null);
const [isReady, setIsReady] = useState(false);
const [steps, setSteps] = useState<number[]>([]);
const layerInstancesRef = useRef<Map<WeatherLayerId, AnyWeatherLayer>>(new Map());
const apiKeySetRef = useRef(false);
/** SDK raw 시간 범위 (초 단위) */
const animRangeRef = useRef<{ start: number; end: number } | null>(null);
// 레이어 add effect 안의 async 콜백에서 최신 isPlaying/animationSpeed를 읽기 위한 ref
const isPlayingRef = useRef(isPlaying);
isPlayingRef.current = isPlaying;
const animationSpeedRef = useRef(animationSpeed);
animationSpeedRef.current = animationSpeed;
// API key 설정 + ServiceWorker 등록
useEffect(() => {
if (apiKeySetRef.current) return;
const key = getMapTilerKey();
if (key) {
maptilerConfig.apiKey = key;
apiKeySetRef.current = true;
}
// 타일 캐시 SW 등록 (실패해도 무시 — 캐시 없이도 동작)
if ('serviceWorker' in navigator) {
navigator.serviceWorker.register('/sw-weather-cache.js').catch(() => {});
}
}, []);
// maplibre-gl Map에 MapTiler SDK 전용 메서드/프로퍼티 패치
useEffect(() => {
if (!map) return;
// eslint-disable-next-line @typescript-eslint/no-explicit-any
const m = map as any;
if (typeof m.getSdkConfig === 'function') return;
m.getSdkConfig = () => maptilerConfig;
m.getMaptilerSessionId = () => '';
m.isGlobeProjection = () => map.getProjection?.()?.type === 'globe';
if (!m.telemetry) {
m.telemetry = { registerModule: () => {} };
}
}, [map]);
// enabled 변경 시 레이어 추가/제거
useEffect(() => {
if (!map) return;
if (!apiKeySetRef.current) return;
const instances = layerInstancesRef.current;
for (const meta of WEATHER_LAYERS) {
const isOn = enabled[meta.id];
const existing = instances.get(meta.id);
if (isOn && !existing) {
const layer = createLayerInstance(meta.id, opacity);
instances.set(meta.id, layer);
try {
// eslint-disable-next-line @typescript-eslint/no-explicit-any
map.addLayer(layer as any);
// 소스가 준비되면 시간 범위 설정 + 재생 상태 적용
layer.onSourceReadyAsync().then(() => {
if (!instances.has(meta.id)) return;
// SDK 내부 시간은 epoch 초 단위
const rawStart = layer.getAnimationStart();
const rawEnd = layer.getAnimationEnd();
animRangeRef.current = { start: rawStart, end: rawEnd };
setStartTime(layer.getAnimationStartDate());
setEndTime(layer.getAnimationEndDate());
setSteps(buildSteps(rawStart, rawEnd));
// 시작 시간으로 초기화 (초 단위 전달)
layer.setAnimationTime(rawStart);
setCurrentTime(layer.getAnimationStartDate());
setIsReady(true);
// 재생 중이었다면 새 레이어에도 재생 적용
if (isPlayingRef.current) {
layer.animateByFactor(animationSpeedRef.current);
}
});
} catch (e) {
if (import.meta.env.DEV) {
console.warn(`[WeatherOverlay] Failed to add layer ${meta.id}:`, e);
}
instances.delete(meta.id);
}
} else if (!isOn && existing) {
try {
// eslint-disable-next-line @typescript-eslint/no-explicit-any
if (map.getLayer((existing as any).id)) {
// eslint-disable-next-line @typescript-eslint/no-explicit-any
map.removeLayer((existing as any).id);
}
} catch {
// ignore
}
instances.delete(meta.id);
}
}
if (instances.size === 0) {
setIsReady(false);
setStartTime(null);
setEndTime(null);
setCurrentTime(null);
setSteps([]);
animRangeRef.current = null;
}
}, [enabled, map, opacity]);
// opacity 변경 시 기존 레이어에 반영
useEffect(() => {
for (const layer of layerInstancesRef.current.values()) {
layer.setOpacity(opacity);
}
}, [opacity]);
// 애니메이션 상태 동기화
useEffect(() => {
for (const layer of layerInstancesRef.current.values()) {
if (isPlaying) {
layer.animateByFactor(animationSpeed);
} else {
layer.animateByFactor(0);
}
}
}, [isPlaying, animationSpeed]);
// 재생 중 rAF 폴링으로 currentTime 동기화 (~4fps)
useEffect(() => {
if (!isPlaying) return;
const instances = layerInstancesRef.current;
if (instances.size === 0) return;
let rafId: number;
let lastUpdate = 0;
const poll = () => {
const now = performance.now();
if (now - lastUpdate > 250) {
lastUpdate = now;
const first = instances.values().next().value;
if (first) {
setCurrentTime(first.getAnimationTimeDate());
}
}
rafId = requestAnimationFrame(poll);
};
rafId = requestAnimationFrame(poll);
return () => cancelAnimationFrame(rafId);
}, [isPlaying]);
// cleanup on map change or unmount
useEffect(() => {
return () => {
for (const [id, layer] of layerInstancesRef.current) {
try {
// eslint-disable-next-line @typescript-eslint/no-explicit-any
if (map?.getLayer((layer as any).id)) {
// eslint-disable-next-line @typescript-eslint/no-explicit-any
map.removeLayer((layer as any).id);
}
} catch {
// ignore
}
void id;
}
layerInstancesRef.current.clear();
};
}, [map]);
// 라디오 버튼 동작: 하나만 활성, 다시 누르면 전부 off
const toggleLayer = useCallback((id: WeatherLayerId) => {
setEnabled((prev) => {
const next = { ...DEFAULT_ENABLED };
if (!prev[id]) next[id] = true;
return next;
});
// 레이어 전환 시 isReady 리셋 (새 소스 로딩 대기)
setIsReady(false);
}, []);
const setOpacity = useCallback((v: number) => {
setOpacityState(Math.max(0, Math.min(1, v)));
}, []);
const play = useCallback(() => setIsPlaying(true), []);
const pause = useCallback(() => setIsPlaying(false), []);
const setSpeed = useCallback((factor: number) => {
setAnimationSpeed(factor);
}, []);
/** epochSec = SDK 내부 초 단위 시간 */
const seekTo = useCallback((epochSec: number) => {
for (const layer of layerInstancesRef.current.values()) {
layer.setAnimationTime(epochSec);
}
setCurrentTime(new Date(epochSec * 1000));
// SDK CustomLayerInterface.render() 가 호출되어야 타일이 실제 갱신됨
// 여러 프레임에 걸쳐 repaint 트리거
if (map) {
let count = 0;
const kick = () => {
map.triggerRepaint();
if (++count < 6) requestAnimationFrame(kick);
};
kick();
}
}, [map]);
const activeLayerId = (Object.keys(enabled) as WeatherLayerId[]).find((k) => enabled[k]) ?? null;
return {
enabled,
activeLayerId,
opacity,
isPlaying,
animationSpeed,
currentTime,
startTime,
endTime,
steps,
isReady,
toggleLayer,
setOpacity,
play,
pause,
setSpeed,
seekTo,
};
}

파일 보기

@ -0,0 +1,97 @@
import { useCallback, useEffect, useMemo, useRef, useState } from 'react';
import type { ZonesGeoJson } from '../../entities/zone/api/useZones';
import { ZONE_META, type ZoneId } from '../../entities/zone/model/meta';
import type { WeatherQueryPoint, WeatherSnapshot } from '../../entities/weather/model/types';
import { fetchWeatherForPoints } from '../../entities/weather/api/fetchWeather';
import { computeMultiPolygonCentroid } from '../../entities/weather/lib/weatherUtils';
const POLL_INTERVAL_MS = 5 * 60 * 1000; // 5분
/**
* zones GeoJSON에서 (centroid) .
* zoneId Feature만 .
*/
function buildQueryPoints(zones: ZonesGeoJson): WeatherQueryPoint[] {
const points: WeatherQueryPoint[] = [];
for (const feature of zones.features) {
const zoneId = feature.properties?.zoneId as ZoneId | undefined;
if (!zoneId || !ZONE_META[zoneId]) continue;
const meta = ZONE_META[zoneId];
const [lon, lat] = computeMultiPolygonCentroid(
feature.geometry.coordinates as number[][][][],
);
points.push({
label: `${meta.label} ${meta.name}`,
color: meta.color,
lat,
lon,
zoneId,
});
}
return points;
}
export interface WeatherPollingResult {
snapshot: WeatherSnapshot | null;
isLoading: boolean;
error: string | null;
refresh: () => void;
}
/**
* 5 .
* zonesGeoJson이 null이면 ( ).
*/
export function useWeatherPolling(
zonesGeoJson: ZonesGeoJson | null,
): WeatherPollingResult {
const [snapshot, setSnapshot] = useState<WeatherSnapshot | null>(null);
const [isLoading, setIsLoading] = useState(false);
const [error, setError] = useState<string | null>(null);
const abortRef = useRef<AbortController | null>(null);
const queryPoints = useMemo(() => {
if (!zonesGeoJson) return null;
return buildQueryPoints(zonesGeoJson);
}, [zonesGeoJson]);
const doFetch = useCallback(async () => {
if (!queryPoints || queryPoints.length === 0) return;
abortRef.current?.abort();
const ac = new AbortController();
abortRef.current = ac;
setIsLoading(true);
setError(null);
try {
const result = await fetchWeatherForPoints(queryPoints);
if (ac.signal.aborted) return;
setSnapshot(result);
} catch (e) {
if (ac.signal.aborted) return;
setError(e instanceof Error ? e.message : String(e));
} finally {
if (!ac.signal.aborted) setIsLoading(false);
}
}, [queryPoints]);
// 마운트 + queryPoints 변경 시 즉시 1회 호출 + 5분 간격 폴링
useEffect(() => {
if (!queryPoints || queryPoints.length === 0) return;
void doFetch();
const timer = setInterval(() => void doFetch(), POLL_INTERVAL_MS);
return () => {
clearInterval(timer);
abortRef.current?.abort();
};
}, [queryPoints, doFetch]);
return { snapshot, isLoading, error, refresh: doFetch };
}

파일 보기

@ -1,4 +1,6 @@
import { useEffect, useMemo, useRef, useState } from "react"; import { useCallback, useEffect, useMemo, useRef, useState } from "react";
import { useAuth } from "../../shared/auth";
import { usePersistedState } from "../../shared/hooks";
import { useAisTargetPolling } from "../../features/aisPolling/useAisTargetPolling"; import { useAisTargetPolling } from "../../features/aisPolling/useAisTargetPolling";
import { Map3DSettingsToggles } from "../../features/map3dSettings/Map3DSettingsToggles"; import { Map3DSettingsToggles } from "../../features/map3dSettings/Map3DSettingsToggles";
import type { MapToggleState } from "../../features/mapToggles/MapToggles"; import type { MapToggleState } from "../../features/mapToggles/MapToggles";
@ -18,6 +20,7 @@ import { AisTargetList } from "../../widgets/aisTargetList/AisTargetList";
import { AlarmsPanel } from "../../widgets/alarms/AlarmsPanel"; import { AlarmsPanel } from "../../widgets/alarms/AlarmsPanel";
import { MapLegend } from "../../widgets/legend/MapLegend"; import { MapLegend } from "../../widgets/legend/MapLegend";
import { Map3D, type BaseMapId, type Map3DSettings, type MapProjectionId } from "../../widgets/map3d/Map3D"; import { Map3D, type BaseMapId, type Map3DSettings, type MapProjectionId } from "../../widgets/map3d/Map3D";
import type { MapViewState } from "../../widgets/map3d/types";
import { RelationsPanel } from "../../widgets/relations/RelationsPanel"; import { RelationsPanel } from "../../widgets/relations/RelationsPanel";
import { SpeedProfilePanel } from "../../widgets/speed/SpeedProfilePanel"; import { SpeedProfilePanel } from "../../widgets/speed/SpeedProfilePanel";
import { Topbar } from "../../widgets/topbar/Topbar"; import { Topbar } from "../../widgets/topbar/Topbar";
@ -28,6 +31,14 @@ import { MapSettingsPanel } from "../../features/mapSettings/MapSettingsPanel";
import { DepthLegend } from "../../widgets/legend/DepthLegend"; import { DepthLegend } from "../../widgets/legend/DepthLegend";
import { DEFAULT_MAP_STYLE_SETTINGS } from "../../features/mapSettings/types"; import { DEFAULT_MAP_STYLE_SETTINGS } from "../../features/mapSettings/types";
import type { MapStyleSettings } from "../../features/mapSettings/types"; import type { MapStyleSettings } from "../../features/mapSettings/types";
import { fmtDateTimeFull, fmtIsoFull } from "../../shared/lib/datetime";
import { queryTrackByMmsi } from "../../features/trackReplay/services/trackQueryService";
import { useTrackQueryStore } from "../../features/trackReplay/stores/trackQueryStore";
import { GlobalTrackReplayPanel } from "../../widgets/trackReplay/GlobalTrackReplayPanel";
import { useWeatherPolling } from "../../features/weatherOverlay/useWeatherPolling";
import { useWeatherOverlay } from "../../features/weatherOverlay/useWeatherOverlay";
import { WeatherPanel } from "../../widgets/weatherPanel/WeatherPanel";
import { WeatherOverlayPanel } from "../../widgets/weatherOverlay/WeatherOverlayPanel";
import { import {
buildLegacyHitMap, buildLegacyHitMap,
computeCountsByType, computeCountsByType,
@ -47,13 +58,6 @@ const AIS_CENTER = {
radiusMeters: 2_000_000, radiusMeters: 2_000_000,
}; };
function fmtLocal(iso: string | null) {
if (!iso) return "-";
const d = new Date(iso);
if (Number.isNaN(d.getTime())) return iso;
return d.toLocaleString("ko-KR", { hour12: false });
}
type Bbox = [number, number, number, number]; // [lonMin, latMin, lonMax, latMax] type Bbox = [number, number, number, number]; // [lonMin, latMin, lonMax, latMax]
type FleetRelationSortMode = "count" | "range"; type FleetRelationSortMode = "count" | "range";
@ -74,22 +78,29 @@ function useLegacyIndex(data: LegacyVesselDataset | null): LegacyVesselIndex | n
} }
export function DashboardPage() { export function DashboardPage() {
const { user, logout } = useAuth();
const { data: zones, error: zonesError } = useZones(); const { data: zones, error: zonesError } = useZones();
const { data: legacyData, error: legacyError } = useLegacyVessels(); const { data: legacyData, error: legacyError } = useLegacyVessels();
const { data: subcableData } = useSubcables(); const { data: subcableData } = useSubcables();
const legacyIndex = useLegacyIndex(legacyData); const legacyIndex = useLegacyIndex(legacyData);
const weather = useWeatherPolling(zones);
const [mapInstance, setMapInstance] = useState<import("maplibre-gl").Map | null>(null);
const weatherOverlay = useWeatherOverlay(mapInstance);
const handleMapReady = useCallback((map: import("maplibre-gl").Map) => {
setMapInstance(map);
}, []);
const [viewBbox, setViewBbox] = useState<Bbox | null>(null); const [viewBbox, setViewBbox] = useState<Bbox | null>(null);
const [useViewportFilter, setUseViewportFilter] = useState(false); const [useViewportFilter, setUseViewportFilter] = useState(false);
const [useApiBbox, setUseApiBbox] = useState(false); const [useApiBbox, setUseApiBbox] = useState(false);
const [apiBbox, setApiBbox] = useState<string | undefined>(undefined); const [apiBbox, setApiBbox] = useState<string | undefined>(undefined);
const { targets, snapshot } = useAisTargetPolling({ const { targets, snapshot } = useAisTargetPolling({
initialMinutes: 60, chnprmshipMinutes: 120,
bootstrapMinutes: 10,
incrementalMinutes: 2, incrementalMinutes: 2,
intervalMs: 60_000, intervalMs: 60_000,
retentionMinutes: 90, retentionMinutes: 120,
bbox: useApiBbox ? apiBbox : undefined, bbox: useApiBbox ? apiBbox : undefined,
centerLon: useApiBbox ? undefined : AIS_CENTER.lon, centerLon: useApiBbox ? undefined : AIS_CENTER.lon,
centerLat: useApiBbox ? undefined : AIS_CENTER.lat, centerLat: useApiBbox ? undefined : AIS_CENTER.lat,
@ -102,55 +113,83 @@ export function DashboardPage() {
const [hoveredFleetMmsiSet, setHoveredFleetMmsiSet] = useState<number[]>([]); const [hoveredFleetMmsiSet, setHoveredFleetMmsiSet] = useState<number[]>([]);
const [hoveredPairMmsiSet, setHoveredPairMmsiSet] = useState<number[]>([]); const [hoveredPairMmsiSet, setHoveredPairMmsiSet] = useState<number[]>([]);
const [hoveredFleetOwnerKey, setHoveredFleetOwnerKey] = useState<string | null>(null); const [hoveredFleetOwnerKey, setHoveredFleetOwnerKey] = useState<string | null>(null);
const [typeEnabled, setTypeEnabled] = useState<Record<VesselTypeCode, boolean>>({ const uid = user?.id ?? null;
PT: true, const [typeEnabled, setTypeEnabled] = usePersistedState<Record<VesselTypeCode, boolean>>(
"PT-S": true, uid, 'typeEnabled', { PT: true, "PT-S": true, GN: true, OT: true, PS: true, FC: true },
GN: true, );
OT: true, const [showTargets, setShowTargets] = usePersistedState(uid, 'showTargets', true);
PS: true, const [showOthers, setShowOthers] = usePersistedState(uid, 'showOthers', false);
FC: true,
});
const [showTargets, setShowTargets] = useState(true);
const [showOthers, setShowOthers] = useState(false);
// 레거시 베이스맵 비활성 — 향후 위성/라이트 등 추가 시 재활용 // 레거시 베이스맵 비활성 — 향후 위성/라이트 등 추가 시 재활용
// eslint-disable-next-line @typescript-eslint/no-unused-vars // eslint-disable-next-line @typescript-eslint/no-unused-vars
const [baseMap, _setBaseMap] = useState<BaseMapId>("enhanced"); const [baseMap, _setBaseMap] = useState<BaseMapId>("enhanced");
const [projection, setProjection] = useState<MapProjectionId>("mercator"); // 항상 mercator로 시작 — 3D 전환은 globe 레이어 준비 후 사용자가 수동 전환
const [mapStyleSettings, setMapStyleSettings] = useState<MapStyleSettings>(DEFAULT_MAP_STYLE_SETTINGS); const [projection, setProjection] = useState<MapProjectionId>('mercator');
const [mapStyleSettings, setMapStyleSettings] = usePersistedState<MapStyleSettings>(uid, 'mapStyleSettings', DEFAULT_MAP_STYLE_SETTINGS);
const [overlays, setOverlays] = useState<MapToggleState>({ const [overlays, setOverlays] = usePersistedState<MapToggleState>(uid, 'overlays', {
pairLines: true, pairLines: true, pairRange: true, fcLines: true, zones: true,
pairRange: true, fleetCircles: true, predictVectors: true, shipLabels: true, subcables: false,
fcLines: true,
zones: true,
fleetCircles: true,
predictVectors: true,
shipLabels: true,
subcables: false,
}); });
const [fleetRelationSortMode, setFleetRelationSortMode] = useState<FleetRelationSortMode>("count"); const [fleetRelationSortMode, setFleetRelationSortMode] = usePersistedState<FleetRelationSortMode>(uid, 'sortMode', "count");
const [alarmKindEnabled, setAlarmKindEnabled] = useState<Record<LegacyAlarmKind, boolean>>(() => { const [alarmKindEnabled, setAlarmKindEnabled] = usePersistedState<Record<LegacyAlarmKind, boolean>>(
return Object.fromEntries(LEGACY_ALARM_KINDS.map((k) => [k, true])) as Record<LegacyAlarmKind, boolean>; uid, 'alarmKindEnabled',
}); () => Object.fromEntries(LEGACY_ALARM_KINDS.map((k) => [k, true])) as Record<LegacyAlarmKind, boolean>,
);
const [fleetFocus, setFleetFocus] = useState<{ id: string | number; center: [number, number]; zoom?: number } | undefined>(undefined); const [fleetFocus, setFleetFocus] = useState<{ id: string | number; center: [number, number]; zoom?: number } | undefined>(undefined);
const [hoveredCableId, setHoveredCableId] = useState<string | null>(null); const [hoveredCableId, setHoveredCableId] = useState<string | null>(null);
const [selectedCableId, setSelectedCableId] = useState<string | null>(null); const [selectedCableId, setSelectedCableId] = useState<string | null>(null);
const [settings, setSettings] = useState<Map3DSettings>({ // 항적 (vessel track)
showShips: true, const [trackContextMenu, setTrackContextMenu] = useState<{ x: number; y: number; mmsi: number; vesselName: string } | null>(null);
showDensity: false, const handleOpenTrackMenu = useCallback((info: { x: number; y: number; mmsi: number; vesselName: string }) => {
showSeamark: false, setTrackContextMenu(info);
}, []);
const handleCloseTrackMenu = useCallback(() => setTrackContextMenu(null), []);
const handleRequestTrack = useCallback(async (mmsi: number, minutes: number) => {
const trackStore = useTrackQueryStore.getState();
const queryKey = `${mmsi}:${minutes}:${Date.now()}`;
trackStore.beginQuery(queryKey);
try {
const target = targets.find((item) => item.mmsi === mmsi);
const tracks = await queryTrackByMmsi({
mmsi,
minutes,
shipNameHint: target?.name,
}); });
const [isProjectionLoading, setIsProjectionLoading] = useState(false); if (tracks.length > 0) {
trackStore.applyTracksSuccess(tracks, queryKey);
} else {
trackStore.applyQueryError('항적 데이터가 없습니다.', queryKey);
}
} catch (e) {
trackStore.applyQueryError(e instanceof Error ? e.message : '항적 조회에 실패했습니다.', queryKey);
}
}, [targets]);
const [clock, setClock] = useState(() => new Date().toLocaleString("ko-KR", { hour12: false })); const [settings, setSettings] = usePersistedState<Map3DSettings>(uid, 'map3dSettings', {
showShips: true, showDensity: false, showSeamark: false,
});
const [mapView, setMapView] = usePersistedState<MapViewState | null>(uid, 'mapView', null);
const [isProjectionLoading, setIsProjectionLoading] = useState(false);
// 초기값 false: globe 레이어가 백그라운드에서 준비될 때까지 토글 비활성화
const [isGlobeShipsReady, setIsGlobeShipsReady] = useState(false);
const handleProjectionLoadingChange = useCallback((loading: boolean) => {
setIsProjectionLoading(loading);
}, []);
const showMapLoader = isProjectionLoading;
// globe 레이어 미준비 또는 전환 중일 때 토글 비활성화
const isProjectionToggleDisabled = !isGlobeShipsReady || isProjectionLoading;
const [clock, setClock] = useState(() => fmtDateTimeFull(new Date()));
useEffect(() => { useEffect(() => {
const id = window.setInterval(() => setClock(new Date().toLocaleString("ko-KR", { hour12: false })), 1000); const id = window.setInterval(() => setClock(fmtDateTimeFull(new Date())), 1000);
return () => window.clearInterval(id); return () => window.clearInterval(id);
}, []); }, []);
@ -310,6 +349,8 @@ export function DashboardPage() {
clock={clock} clock={clock}
adminMode={adminMode} adminMode={adminMode}
onLogoClick={onLogoClick} onLogoClick={onLogoClick}
userName={user?.name}
onLogout={logout}
/> />
<div className="sidebar"> <div className="sidebar">
@ -363,10 +404,10 @@ export function DashboardPage() {
<div style={{ flex: 1 }} /> <div style={{ flex: 1 }} />
<div <div
className={`tog-btn ${projection === "globe" ? "on" : ""}`} className={`tog-btn ${projection === "globe" ? "on" : ""}${isProjectionToggleDisabled ? " disabled" : ""}`}
onClick={() => setProjection((p) => (p === "globe" ? "mercator" : "globe"))} onClick={isProjectionToggleDisabled ? undefined : () => setProjection((p) => (p === "globe" ? "mercator" : "globe"))}
title="3D 지구본 투영: 드래그로 회전, 휠로 확대/축소" title={isProjectionToggleDisabled ? "3D 모드 준비 중..." : "3D 지구본 투영: 드래그로 회전, 휠로 확대/축소"}
style={{ fontSize: 9, padding: "2px 8px" }} style={{ fontSize: 9, padding: "2px 8px", opacity: isProjectionToggleDisabled ? 0.4 : 1, cursor: isProjectionToggleDisabled ? "not-allowed" : "pointer" }}
> >
3D 3D
</div> </div>
@ -543,7 +584,7 @@ export function DashboardPage() {
</div> </div>
<div style={{ marginTop: 6, color: "var(--muted)", fontSize: 10 }}> fetch</div> <div style={{ marginTop: 6, color: "var(--muted)", fontSize: 10 }}> fetch</div>
<div> <div>
{fmtLocal(snapshot.lastFetchAt)}{" "} {fmtIsoFull(snapshot.lastFetchAt)}{" "}
<span style={{ color: "var(--muted)", fontSize: 10 }}> <span style={{ color: "var(--muted)", fontSize: 10 }}>
({snapshot.lastFetchMinutes ?? "-"}m, inserted {snapshot.lastInserted}, upserted {snapshot.lastUpserted}) ({snapshot.lastFetchMinutes ?? "-"}m, inserted {snapshot.lastInserted}, upserted {snapshot.lastUpserted})
</span> </span>
@ -567,7 +608,7 @@ export function DashboardPage() {
<span style={{ color: "var(--muted)", fontSize: 10 }}>/ {targetsInScope.length}</span> <span style={{ color: "var(--muted)", fontSize: 10 }}>/ {targetsInScope.length}</span>
</div> </div>
<div style={{ marginTop: 6, color: "var(--muted)", fontSize: 10 }}></div> <div style={{ marginTop: 6, color: "var(--muted)", fontSize: 10 }}></div>
<div style={{ fontSize: 10, color: "var(--text)" }}>{legacyData?.generatedAt ? fmtLocal(legacyData.generatedAt) : "loading..."}</div> <div style={{ fontSize: 10, color: "var(--text)" }}>{legacyData?.generatedAt ? fmtIsoFull(legacyData.generatedAt) : "loading..."}</div>
</div> </div>
)} )}
</div> </div>
@ -678,7 +719,7 @@ export function DashboardPage() {
</div> </div>
<div className="map-area"> <div className="map-area">
{isProjectionLoading ? ( {showMapLoader ? (
<div className="map-loader-overlay" role="status" aria-live="polite"> <div className="map-loader-overlay" role="status" aria-live="polite">
<div className="map-loader-overlay__panel"> <div className="map-loader-overlay__panel">
<div className="map-loader-overlay__spinner" /> <div className="map-loader-overlay__spinner" />
@ -710,7 +751,8 @@ export function DashboardPage() {
fcLinks={fcLinksForMap} fcLinks={fcLinksForMap}
fleetCircles={fleetCirclesForMap} fleetCircles={fleetCirclesForMap}
fleetFocus={fleetFocus} fleetFocus={fleetFocus}
onProjectionLoadingChange={setIsProjectionLoading} onProjectionLoadingChange={handleProjectionLoadingChange}
onGlobeShipsReady={setIsGlobeShipsReady}
onHoverMmsi={(mmsis) => setHoveredMmsiSet(setUniqueSorted(mmsis))} onHoverMmsi={(mmsis) => setHoveredMmsiSet(setUniqueSorted(mmsis))}
onClearMmsiHover={() => setHoveredMmsiSet((prev) => (prev.length === 0 ? prev : []))} onClearMmsiHover={() => setHoveredMmsiSet((prev) => (prev.length === 0 ? prev : []))}
onHoverPair={(pairMmsis) => setHoveredPairMmsiSet(setUniqueSorted(pairMmsis))} onHoverPair={(pairMmsis) => setHoveredPairMmsiSet(setUniqueSorted(pairMmsis))}
@ -728,7 +770,23 @@ export function DashboardPage() {
onHoverCable={setHoveredCableId} onHoverCable={setHoveredCableId}
onClickCable={(id) => setSelectedCableId((prev) => (prev === id ? null : id))} onClickCable={(id) => setSelectedCableId((prev) => (prev === id ? null : id))}
mapStyleSettings={mapStyleSettings} mapStyleSettings={mapStyleSettings}
initialView={mapView}
onViewStateChange={setMapView}
activeTrack={null}
trackContextMenu={trackContextMenu}
onRequestTrack={handleRequestTrack}
onCloseTrackMenu={handleCloseTrackMenu}
onOpenTrackMenu={handleOpenTrackMenu}
onMapReady={handleMapReady}
/> />
<GlobalTrackReplayPanel />
<WeatherPanel
snapshot={weather.snapshot}
isLoading={weather.isLoading}
error={weather.error}
onRefresh={weather.refresh}
/>
<WeatherOverlayPanel {...weatherOverlay} />
<MapSettingsPanel value={mapStyleSettings} onChange={setMapStyleSettings} /> <MapSettingsPanel value={mapStyleSettings} onChange={setMapStyleSettings} />
<DepthLegend depthStops={mapStyleSettings.depthStops} /> <DepthLegend depthStops={mapStyleSettings.depthStops} />
<MapLegend /> <MapLegend />

파일 보기

@ -0,0 +1,44 @@
import { Navigate } from 'react-router';
import { useAuth } from '../../shared/auth';
export function DeniedPage() {
const { user, loading, logout } = useAuth();
if (loading) {
return (
<div className="auth-loading">
<div className="auth-loading__spinner" />
</div>
);
}
if (!user) {
return <Navigate to="/login" replace />;
}
const isRejectedOrDisabled =
user.status === 'REJECTED' || user.status === 'DISABLED';
const hasWingPermit = user.roles.some((r) => r.name === 'WING_PERMIT');
return (
<div className="auth-page">
<div className="auth-card">
<div className="auth-status-icon">&#128683;</div>
<div className="auth-title"> </div>
<div className="auth-message">
{isRejectedOrDisabled
? `계정이 ${user.status === 'REJECTED' ? '거절' : '비활성화'}되었습니다.`
: !hasWingPermit
? 'WING 대시보드 접근 권한이 없습니다. 관리자에게 WING_PERMIT 역할을 요청하세요.'
: '접근이 거부되었습니다.'}
</div>
<div className="auth-message" style={{ fontSize: 11, marginTop: 8 }}>
{user.email}
</div>
<button className="auth-link-btn" onClick={logout}>
</button>
</div>
</div>
);
}

파일 보기

@ -0,0 +1,70 @@
import { useState } from 'react';
import { Navigate } from 'react-router';
import { GoogleLogin, GoogleOAuthProvider } from '@react-oauth/google';
import { useAuth } from '../../shared/auth';
const GOOGLE_CLIENT_ID = import.meta.env.VITE_GOOGLE_CLIENT_ID || '';
export function LoginPage() {
const { user, login, devLogin, loading } = useAuth();
const [error, setError] = useState<string | null>(null);
if (loading) {
return (
<div className="auth-loading">
<div className="auth-loading__spinner" />
</div>
);
}
if (user) {
return <Navigate to="/" replace />;
}
const handleSuccess = async (credentialResponse: { credential?: string }) => {
if (!credentialResponse.credential) {
setError('Google 인증 토큰을 받지 못했습니다.');
return;
}
try {
setError(null);
await login(credentialResponse.credential);
} catch (e) {
setError(e instanceof Error ? e.message : '로그인에 실패했습니다.');
}
};
return (
<div className="auth-page">
<div className="auth-card">
<div className="auth-logo"><span style={{ position: 'relative' }}>GC WING<span style={{ position: 'absolute', right: -48, bottom: 0, color: '#F59E0B', fontSize: 12, fontWeight: 600 }}>demo</span></span></div>
<div className="auth-title"> </div>
<div className="auth-subtitle">@gcsc.co.kr </div>
{error && <div className="auth-error">{error}</div>}
<div className="auth-google-btn">
<GoogleOAuthProvider clientId={GOOGLE_CLIENT_ID}>
<GoogleLogin
onSuccess={handleSuccess}
onError={() => setError('Google 로그인에 실패했습니다.')}
theme="filled_black"
size="large"
width="280"
/>
</GoogleOAuthProvider>
</div>
{devLogin && (
<button className="auth-dev-btn" onClick={devLogin}>
DEV Mock
</button>
)}
<div className="auth-footer">
GC SI Team &middot; Wing Fleet Dashboard
</div>
</div>
</div>
);
}

파일 보기

@ -0,0 +1,39 @@
import { Navigate } from 'react-router';
import { useAuth } from '../../shared/auth';
export function PendingPage() {
const { user, loading, logout } = useAuth();
if (loading) {
return (
<div className="auth-loading">
<div className="auth-loading__spinner" />
</div>
);
}
if (!user) {
return <Navigate to="/login" replace />;
}
if (user.status !== 'PENDING') {
return <Navigate to="/" replace />;
}
return (
<div className="auth-page">
<div className="auth-card">
<div className="auth-status-icon">&#9203;</div>
<div className="auth-title"> </div>
<div className="auth-message">
<b>{user.email}</b> .
<br />
.
</div>
<button className="auth-link-btn" onClick={logout}>
</button>
</div>
</div>
);
}

파일 보기

@ -0,0 +1,19 @@
import { createContext } from 'react';
import type { User } from './types';
export interface AuthContextValue {
user: User | null;
token: string | null;
loading: boolean;
login: (googleToken: string) => Promise<void>;
devLogin?: () => void;
logout: () => void;
}
export const AuthContext = createContext<AuthContextValue>({
user: null,
token: null,
loading: true,
login: async () => {},
logout: () => {},
});

파일 보기

@ -0,0 +1,99 @@
import { useCallback, useEffect, useMemo, useState, type ReactNode } from 'react';
import type { AuthResponse, User } from './types';
import { authApi } from './authApi';
import { AuthContext } from './AuthContext';
const DEV_MOCK_USER: User = {
id: 1,
email: 'htlee@gcsc.co.kr',
name: '김개발 (DEV)',
avatarUrl: null,
status: 'ACTIVE',
isAdmin: true,
roles: [
{ id: 1, name: 'ADMIN', description: '관리자', urlPatterns: ['/**'] },
{ id: 99, name: 'WING_PERMIT', description: 'Wing 접근 권한', urlPatterns: [] },
],
createdAt: new Date().toISOString(),
lastLoginAt: new Date().toISOString(),
};
function isDevMockSession(): boolean {
return import.meta.env.DEV && localStorage.getItem('dev-user') === 'true';
}
export function AuthProvider({ children }: { children: ReactNode }) {
const [user, setUser] = useState<User | null>(() =>
isDevMockSession() ? DEV_MOCK_USER : null,
);
const [token, setToken] = useState<string | null>(
() => localStorage.getItem('token'),
);
const [initialized, setInitialized] = useState(
() => isDevMockSession() || !localStorage.getItem('token'),
);
const logout = useCallback(() => {
const hadToken = !!localStorage.getItem('token') && !isDevMockSession();
localStorage.removeItem('token');
localStorage.removeItem('dev-user');
setToken(null);
setUser(null);
if (hadToken) {
authApi.post('/auth/logout').catch(() => {});
}
}, []);
const devLogin = useCallback(() => {
localStorage.setItem('dev-user', 'true');
localStorage.setItem('token', 'dev-mock-token');
setToken('dev-mock-token');
setUser(DEV_MOCK_USER);
setInitialized(true);
}, []);
const login = useCallback(async (googleToken: string) => {
const res = await authApi.post<AuthResponse>('/auth/google', {
idToken: googleToken,
});
localStorage.setItem('token', res.token);
setToken(res.token);
setUser(res.user);
}, []);
useEffect(() => {
if (!token || isDevMockSession()) return;
let cancelled = false;
authApi
.get<User>('/auth/me')
.then((data) => {
if (!cancelled) setUser(data);
})
.catch(() => {
if (!cancelled) logout();
})
.finally(() => {
if (!cancelled) setInitialized(true);
});
return () => {
cancelled = true;
};
}, [token, logout]);
const loading = !initialized;
const value = useMemo(
() => ({
user,
token,
loading,
login,
devLogin: import.meta.env.DEV ? devLogin : undefined,
logout,
}),
[user, token, loading, login, devLogin, logout],
);
return <AuthContext.Provider value={value}>{children}</AuthContext.Provider>;
}

파일 보기

@ -0,0 +1,35 @@
import { Navigate, Outlet } from 'react-router';
import { useAuth } from './useAuth';
const REQUIRED_ROLE = 'WING_PERMIT';
export function ProtectedRoute() {
const { user, loading } = useAuth();
if (loading) {
return (
<div className="auth-loading">
<div className="auth-loading__spinner" />
</div>
);
}
if (!user) {
return <Navigate to="/login" replace />;
}
if (user.status === 'PENDING') {
return <Navigate to="/pending" replace />;
}
if (user.status === 'REJECTED' || user.status === 'DISABLED') {
return <Navigate to="/denied" replace />;
}
const hasPermit = user.roles.some((r) => r.name === REQUIRED_ROLE);
if (!hasPermit) {
return <Navigate to="/denied" replace />;
}
return <Outlet />;
}

파일 보기

@ -0,0 +1,34 @@
const API_BASE = (import.meta.env.VITE_AUTH_API_URL || '').replace(/\/$/, '') + '/api';
async function request<T>(path: string, options?: RequestInit): Promise<T> {
const token = localStorage.getItem('token');
const headers: Record<string, string> = {
'Content-Type': 'application/json',
...(token ? { Authorization: `Bearer ${token}` } : {}),
};
const res = await fetch(`${API_BASE}${path}`, { ...options, headers });
if (res.status === 401) {
localStorage.removeItem('token');
window.location.href = '/login';
throw new Error('Unauthorized');
}
if (!res.ok) {
const body = await res.text();
throw new Error(body || `HTTP ${res.status}`);
}
if (res.status === 204 || res.headers.get('content-length') === '0') {
return undefined as T;
}
return res.json();
}
export const authApi = {
get: <T>(path: string) => request<T>(path),
post: <T>(path: string, body?: unknown) =>
request<T>(path, { method: 'POST', body: JSON.stringify(body) }),
};

파일 보기

@ -0,0 +1,4 @@
export { AuthProvider } from './AuthProvider';
export { useAuth } from './useAuth';
export { ProtectedRoute } from './ProtectedRoute';
export type { User, Role, AuthResponse, UserStatus } from './types';

파일 보기

@ -0,0 +1,25 @@
export type UserStatus = 'PENDING' | 'ACTIVE' | 'REJECTED' | 'DISABLED';
export interface Role {
id: number;
name: string;
description: string;
urlPatterns: string[];
}
export interface User {
id: number;
email: string;
name: string;
avatarUrl: string | null;
status: UserStatus;
isAdmin: boolean;
roles: Role[];
createdAt: string;
lastLoginAt: string | null;
}
export interface AuthResponse {
token: string;
user: User;
}

파일 보기

@ -0,0 +1,6 @@
import { useContext } from 'react';
import { AuthContext } from './AuthContext';
export function useAuth() {
return useContext(AuthContext);
}

파일 보기

@ -0,0 +1 @@
export { usePersistedState } from './usePersistedState';

파일 보기

@ -0,0 +1,103 @@
import { useState, useEffect, useRef, type Dispatch, type SetStateAction } from 'react';
const PREFIX = 'wing';
function buildKey(userId: number, name: string): string {
return `${PREFIX}:${userId}:${name}`;
}
function readStorage<T>(key: string, fallback: T): T {
try {
const raw = localStorage.getItem(key);
if (raw === null) return fallback;
return JSON.parse(raw) as T;
} catch {
return fallback;
}
}
function writeStorage<T>(key: string, value: T): void {
try {
localStorage.setItem(key, JSON.stringify(value));
} catch {
// quota exceeded or unavailable — silent
}
}
function resolveDefault<T>(d: T | (() => T)): T {
return typeof d === 'function' ? (d as () => T)() : d;
}
/**
* useState와 API, localStorage .
*
* @param userId null이면 useState처럼 ()
* @param name (e.g. 'typeEnabled')
* @param defaultValue lazy initializer
* @param debounceMs localStorage ( 300ms)
*/
export function usePersistedState<T>(
userId: number | null,
name: string,
defaultValue: T | (() => T),
debounceMs = 300,
): [T, Dispatch<SetStateAction<T>>] {
const resolved = resolveDefault(defaultValue);
const [state, setState] = useState<T>(() => {
if (userId == null) return resolved;
return readStorage(buildKey(userId, name), resolved);
});
const timerRef = useRef<ReturnType<typeof setTimeout> | null>(null);
const stateRef = useRef(state);
const userIdRef = useRef(userId);
const nameRef = useRef(name);
stateRef.current = state;
userIdRef.current = userId;
nameRef.current = name;
// userId 변경 시 해당 사용자의 저장값 재로드
useEffect(() => {
if (userId == null) return;
const stored = readStorage(buildKey(userId, name), resolved);
setState(stored);
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [userId]);
// debounced write
useEffect(() => {
if (userId == null) return;
const key = buildKey(userId, name);
if (timerRef.current != null) clearTimeout(timerRef.current);
timerRef.current = setTimeout(() => {
writeStorage(key, state);
timerRef.current = null;
}, debounceMs);
return () => {
if (timerRef.current != null) {
clearTimeout(timerRef.current);
timerRef.current = null;
}
};
}, [state, userId, name, debounceMs]);
// unmount 시 pending write flush
useEffect(() => {
return () => {
if (timerRef.current != null) {
clearTimeout(timerRef.current);
timerRef.current = null;
}
if (userIdRef.current != null) {
writeStorage(buildKey(userIdRef.current, nameRef.current), stateRef.current);
}
};
// eslint-disable-next-line react-hooks/exhaustive-deps
}, []);
return [state, setState];
}

파일 보기

@ -0,0 +1,50 @@
/**
* &
*
* KST . DISPLAY_TZ .
*/
/** 표시용 타임존. 'UTC' | 'Asia/Seoul' 등 IANA tz 문자열. */
export const DISPLAY_TZ = 'Asia/Seoul' as const;
/** 표시 레이블 (예: "KST") */
export const DISPLAY_TZ_LABEL = 'KST' as const;
/* ── 포맷 함수 ─────────────────────────────────────────────── */
const pad2 = (n: number) => String(n).padStart(2, '0');
/** DISPLAY_TZ 기준으로 Date → "YYYY년 MM월 DD일 HH시 mm분 ss초" */
export function fmtDateTimeFull(date: Date): string {
const parts = new Intl.DateTimeFormat('ko-KR', {
timeZone: DISPLAY_TZ,
year: 'numeric',
month: '2-digit',
day: '2-digit',
hour: '2-digit',
minute: '2-digit',
second: '2-digit',
hour12: false,
}).formatToParts(date);
const p: Record<string, string> = {};
for (const { type, value } of parts) p[type] = value;
return `${p.year}${p.month}${p.day}${p.hour}${pad2(Number(p.minute))}${pad2(Number(p.second))}`;
}
/** ISO 문자열 → "YYYY년 MM월 DD일 HH시 mm분 ss초" (파싱 실패 시 fallback) */
export function fmtIsoFull(iso: string | null | undefined): string {
if (!iso) return '-';
const d = new Date(iso);
if (Number.isNaN(d.getTime())) return String(iso);
return fmtDateTimeFull(d);
}
/** ISO 문자열 → "HH:mm:ss" (시간만) */
export function fmtIsoTime(iso: string | null | undefined): string {
if (!iso) return '';
const d = new Date(iso);
if (Number.isNaN(d.getTime())) return String(iso);
return d.toLocaleTimeString('ko-KR', { timeZone: DISPLAY_TZ, hour12: false });
}

파일 보기

@ -1,5 +1,6 @@
import type { AisTarget } from "../../entities/aisTarget/model/types"; import type { AisTarget } from "../../entities/aisTarget/model/types";
import type { LegacyVesselInfo } from "../../entities/legacyVessel/model/types"; import type { LegacyVesselInfo } from "../../entities/legacyVessel/model/types";
import { fmtIsoFull } from "../../shared/lib/datetime";
type Props = { type Props = {
target: AisTarget; target: AisTarget;
@ -85,11 +86,11 @@ export function AisInfoPanel({ target: t, legacy, onClose }: Props) {
</div> </div>
<div className="ir"> <div className="ir">
<span className="il">Msg TS</span> <span className="il">Msg TS</span>
<span className="iv">{t.messageTimestamp || "-"}</span> <span className="iv">{fmtIsoFull(t.messageTimestamp)}</span>
</div> </div>
<div className="ir"> <div className="ir">
<span className="il">Received</span> <span className="il">Received</span>
<span className="iv">{t.receivedDate || "-"}</span> <span className="iv">{fmtIsoFull(t.receivedDate)}</span>
</div> </div>
</div> </div>
); );

파일 보기

@ -2,6 +2,7 @@ import { useMemo, useState } from "react";
import type { AisTarget } from "../../entities/aisTarget/model/types"; import type { AisTarget } from "../../entities/aisTarget/model/types";
import type { LegacyVesselIndex } from "../../entities/legacyVessel/lib"; import type { LegacyVesselIndex } from "../../entities/legacyVessel/lib";
import { matchLegacyVessel } from "../../entities/legacyVessel/lib"; import { matchLegacyVessel } from "../../entities/legacyVessel/lib";
import { fmtIsoTime } from "../../shared/lib/datetime";
type SortMode = "recent" | "speed"; type SortMode = "recent" | "speed";
@ -23,13 +24,6 @@ function getSpeedColor(sog: unknown) {
return "#64748B"; return "#64748B";
} }
function fmtLocalTime(iso: string | null | undefined) {
if (!iso) return "";
const d = new Date(iso);
if (Number.isNaN(d.getTime())) return String(iso);
return d.toLocaleTimeString("ko-KR", { hour12: false });
}
export function AisTargetList({ targets, selectedMmsi, onSelectMmsi, legacyIndex }: Props) { export function AisTargetList({ targets, selectedMmsi, onSelectMmsi, legacyIndex }: Props) {
const [q, setQ] = useState(""); const [q, setQ] = useState("");
const [mode, setMode] = useState<SortMode>("recent"); const [mode, setMode] = useState<SortMode>("recent");
@ -96,7 +90,7 @@ export function AisTargetList({ targets, selectedMmsi, onSelectMmsi, legacyIndex
const sel = selectedMmsi && t.mmsi === selectedMmsi; const sel = selectedMmsi && t.mmsi === selectedMmsi;
const sp = isFiniteNumber(t.sog) ? t.sog.toFixed(1) : "?"; const sp = isFiniteNumber(t.sog) ? t.sog.toFixed(1) : "?";
const sc = getSpeedColor(t.sog); const sc = getSpeedColor(t.sog);
const ts = fmtLocalTime(t.messageTimestamp); const ts = fmtIsoTime(t.messageTimestamp);
const legacy = legacyIndex ? matchLegacyVessel(t, legacyIndex) : null; const legacy = legacyIndex ? matchLegacyVessel(t, legacyIndex) : null;
const legacyCode = legacy?.shipCode || ""; const legacyCode = legacy?.shipCode || "";

파일 보기

@ -1,6 +1,7 @@
import { ZONE_META } from "../../entities/zone/model/meta"; import { ZONE_META } from "../../entities/zone/model/meta";
import { SPEED_MAX, VESSEL_TYPES } from "../../entities/vessel/model/meta"; import { SPEED_MAX, VESSEL_TYPES } from "../../entities/vessel/model/meta";
import type { DerivedLegacyVessel } from "../../features/legacyDashboard/model/types"; import type { DerivedLegacyVessel } from "../../features/legacyDashboard/model/types";
import { fmtIsoFull } from "../../shared/lib/datetime";
import { haversineNm } from "../../shared/lib/geo/haversineNm"; import { haversineNm } from "../../shared/lib/geo/haversineNm";
import { OVERLAY_RGB, rgbToHex } from "../../shared/lib/map/palette"; import { OVERLAY_RGB, rgbToHex } from "../../shared/lib/map/palette";
@ -75,7 +76,7 @@ export function VesselInfoPanel({ vessel: v, allVessels, onClose, onSelectMmsi }
</div> </div>
<div className="ir"> <div className="ir">
<span className="il">Msg TS</span> <span className="il">Msg TS</span>
<span className="iv">{v.messageTimestamp || "-"}</span> <span className="iv">{fmtIsoFull(v.messageTimestamp)}</span>
</div> </div>
<div className="ir"> <div className="ir">
<span className="il"></span> <span className="il"></span>

파일 보기

@ -26,7 +26,13 @@ import { useGlobeOverlays } from './hooks/useGlobeOverlays';
import { useGlobeInteraction } from './hooks/useGlobeInteraction'; import { useGlobeInteraction } from './hooks/useGlobeInteraction';
import { useDeckLayers } from './hooks/useDeckLayers'; import { useDeckLayers } from './hooks/useDeckLayers';
import { useSubcablesLayer } from './hooks/useSubcablesLayer'; import { useSubcablesLayer } from './hooks/useSubcablesLayer';
import { useTrackReplayLayer } from './hooks/useTrackReplayLayer';
import { useMapStyleSettings } from './hooks/useMapStyleSettings'; import { useMapStyleSettings } from './hooks/useMapStyleSettings';
import { VesselContextMenu } from './components/VesselContextMenu';
import { useLiveShipAdapter } from '../../features/liveRenderer/hooks/useLiveShipAdapter';
import { useLiveShipBatchRender } from '../../features/liveRenderer/hooks/useLiveShipBatchRender';
import { useTrackQueryStore } from '../../features/trackReplay/stores/trackQueryStore';
import { useTrackReplayDeckLayers } from '../../features/trackReplay/hooks/useTrackReplayDeckLayers';
export type { Map3DSettings, BaseMapId, MapProjectionId } from './types'; export type { Map3DSettings, BaseMapId, MapProjectionId } from './types';
@ -66,14 +72,16 @@ export function Map3D({
onHoverCable, onHoverCable,
onClickCable, onClickCable,
mapStyleSettings, mapStyleSettings,
initialView,
onViewStateChange,
onGlobeShipsReady,
activeTrack = null,
trackContextMenu = null,
onRequestTrack,
onCloseTrackMenu,
onOpenTrackMenu,
onMapReady,
}: Props) { }: Props) {
void onHoverFleet;
void onClearFleetHover;
void onHoverMmsi;
void onClearMmsiHover;
void onHoverPair;
void onClearPairHover;
// ── Shared refs ────────────────────────────────────────────────────── // ── Shared refs ──────────────────────────────────────────────────────
const containerRef = useRef<HTMLDivElement | null>(null); const containerRef = useRef<HTMLDivElement | null>(null);
const mapRef = useRef<maplibregl.Map | null>(null); const mapRef = useRef<maplibregl.Map | null>(null);
@ -91,6 +99,7 @@ export function Map3D({
// ── Hover state ────────────────────────────────────────────────────── // ── Hover state ──────────────────────────────────────────────────────
const { const {
hoveredDeckMmsiSet: hoveredDeckMmsiArr,
setHoveredDeckMmsiSet, setHoveredDeckMmsiSet,
setHoveredDeckPairMmsiSet, setHoveredDeckPairMmsiSet,
setHoveredDeckFleetOwnerKey, setHoveredDeckFleetOwnerKey,
@ -189,20 +198,38 @@ export function Map3D({
); );
// ── Ship data memos ────────────────────────────────────────────────── // ── Ship data memos ──────────────────────────────────────────────────
const shipData = useMemo(() => { const rawShipData = useMemo(() => {
return targets.filter((t) => isFiniteNumber(t.lat) && isFiniteNumber(t.lon) && isFiniteNumber(t.mmsi)); return targets.filter((t) => isFiniteNumber(t.lat) && isFiniteNumber(t.lon) && isFiniteNumber(t.mmsi));
}, [targets]); }, [targets]);
const hideLiveShips = useTrackQueryStore((state) => state.hideLiveShips);
const liveShipFeatures = useLiveShipAdapter(rawShipData, legacyHits ?? null);
const { renderedTargets: batchRenderedTargets } = useLiveShipBatchRender(
mapRef,
liveShipFeatures,
rawShipData,
mapSyncEpoch,
);
const shipData = useMemo(
() => (hideLiveShips ? [] : rawShipData),
[hideLiveShips, rawShipData],
);
const shipByMmsi = useMemo(() => { const shipByMmsi = useMemo(() => {
const byMmsi = new Map<number, AisTarget>(); const byMmsi = new Map<number, AisTarget>();
for (const t of shipData) byMmsi.set(t.mmsi, t); for (const t of rawShipData) byMmsi.set(t.mmsi, t);
return byMmsi; return byMmsi;
}, [shipData]); }, [rawShipData]);
const shipLayerData = useMemo(() => { const shipLayerData = useMemo(() => {
if (shipData.length === 0) return shipData; if (hideLiveShips) return [];
return [...shipData]; // Fallback to raw targets when batch result is temporarily empty
}, [shipData]); // (e.g. overlay update race or viewport sync delay).
if (batchRenderedTargets.length === 0) return rawShipData;
return [...batchRenderedTargets];
}, [hideLiveShips, batchRenderedTargets, rawShipData]);
const shipHighlightSet = useMemo(() => { const shipHighlightSet = useMemo(() => {
const out = new Set(highlightedMmsiSetForShips); const out = new Set(highlightedMmsiSetForShips);
@ -224,6 +251,8 @@ export function Map3D({
return shipLayerData.filter((target) => shipHighlightSet.has(target.mmsi)); return shipLayerData.filter((target) => shipHighlightSet.has(target.mmsi));
}, [shipHighlightSet, shipLayerData]); }, [shipHighlightSet, shipLayerData]);
const trackReplayRenderState = useTrackReplayDeckLayers();
// ── Deck hover management ──────────────────────────────────────────── // ── Deck hover management ────────────────────────────────────────────
const hasAuxiliarySelectModifier = useCallback( const hasAuxiliarySelectModifier = useCallback(
(ev?: { shiftKey?: boolean; ctrlKey?: boolean; metaKey?: boolean } | null): boolean => { (ev?: { shiftKey?: boolean; ctrlKey?: boolean; metaKey?: boolean } | null): boolean => {
@ -283,22 +312,51 @@ export function Map3D({
ownerKey: null, ownerKey: null,
vesselMmsis: [], vesselMmsis: [],
}); });
const mapDrivenMmsiHoverRef = useRef(false);
const mapDrivenPairHoverRef = useRef(false);
const mapDrivenFleetHoverRef = useRef(false);
const clearMapFleetHoverState = useCallback(() => { const clearMapFleetHoverState = useCallback(() => {
const prev = mapFleetHoverStateRef.current;
mapFleetHoverStateRef.current = { ownerKey: null, vesselMmsis: [] }; mapFleetHoverStateRef.current = { ownerKey: null, vesselMmsis: [] };
setHoveredDeckFleetOwner(null); setHoveredDeckFleetOwner(null);
setHoveredDeckFleetMmsis([]); setHoveredDeckFleetMmsis([]);
}, [setHoveredDeckFleetOwner, setHoveredDeckFleetMmsis]); if (
mapDrivenFleetHoverRef.current &&
(prev.ownerKey != null || prev.vesselMmsis.length > 0) &&
hoveredFleetOwnerKey === prev.ownerKey &&
equalNumberArrays(hoveredFleetMmsiSet, prev.vesselMmsis)
) {
onClearFleetHover?.();
}
mapDrivenFleetHoverRef.current = false;
}, [
setHoveredDeckFleetOwner,
setHoveredDeckFleetMmsis,
onClearFleetHover,
hoveredFleetOwnerKey,
hoveredFleetMmsiSet,
]);
const clearDeckHoverPairs = useCallback(() => { const clearDeckHoverPairs = useCallback(() => {
const prev = mapDeckPairHoverRef.current;
mapDeckPairHoverRef.current = []; mapDeckPairHoverRef.current = [];
setHoveredDeckPairMmsiSet((prevState) => (prevState.length === 0 ? prevState : [])); setHoveredDeckPairMmsiSet((prevState) => (prevState.length === 0 ? prevState : []));
}, [setHoveredDeckPairMmsiSet]); if (mapDrivenPairHoverRef.current && prev.length > 0 && equalNumberArrays(hoveredPairMmsiSet, prev)) {
onClearPairHover?.();
}
mapDrivenPairHoverRef.current = false;
}, [setHoveredDeckPairMmsiSet, onClearPairHover, hoveredPairMmsiSet]);
const clearDeckHoverMmsi = useCallback(() => { const clearDeckHoverMmsi = useCallback(() => {
const prev = mapDeckMmsiHoverRef.current;
mapDeckMmsiHoverRef.current = []; mapDeckMmsiHoverRef.current = [];
setHoveredDeckMmsiSet((prevState) => (prevState.length === 0 ? prevState : [])); setHoveredDeckMmsiSet((prevState) => (prevState.length === 0 ? prevState : []));
}, [setHoveredDeckMmsiSet]); if (mapDrivenMmsiHoverRef.current && prev.length > 0 && equalNumberArrays(hoveredMmsiSet, prev)) {
onClearMmsiHover?.();
}
mapDrivenMmsiHoverRef.current = false;
}, [setHoveredDeckMmsiSet, onClearMmsiHover, hoveredMmsiSet]);
const scheduleDeckHoverResolve = useCallback(() => { const scheduleDeckHoverResolve = useCallback(() => {
if (deckHoverRafRef.current != null) return; if (deckHoverRafRef.current != null) return;
@ -324,21 +382,41 @@ export function Map3D({
const setDeckHoverMmsi = useCallback( const setDeckHoverMmsi = useCallback(
(next: number[]) => { (next: number[]) => {
const normalized = makeUniqueSorted(next); const normalized = makeUniqueSorted(next);
const prev = mapDeckMmsiHoverRef.current;
touchDeckHoverState(normalized.length > 0); touchDeckHoverState(normalized.length > 0);
setHoveredDeckMmsiSet((prev) => (equalNumberArrays(prev, normalized) ? prev : normalized)); setHoveredDeckMmsiSet((prev) => (equalNumberArrays(prev, normalized) ? prev : normalized));
mapDeckMmsiHoverRef.current = normalized; mapDeckMmsiHoverRef.current = normalized;
if (!equalNumberArrays(prev, normalized)) {
if (normalized.length > 0) {
mapDrivenMmsiHoverRef.current = true;
onHoverMmsi?.(normalized);
} else if (mapDrivenMmsiHoverRef.current && prev.length > 0) {
onClearMmsiHover?.();
mapDrivenMmsiHoverRef.current = false;
}
}
}, },
[setHoveredDeckMmsiSet, touchDeckHoverState], [setHoveredDeckMmsiSet, touchDeckHoverState, onHoverMmsi, onClearMmsiHover],
); );
const setDeckHoverPairs = useCallback( const setDeckHoverPairs = useCallback(
(next: number[]) => { (next: number[]) => {
const normalized = makeUniqueSorted(next); const normalized = makeUniqueSorted(next);
const prev = mapDeckPairHoverRef.current;
touchDeckHoverState(normalized.length > 0); touchDeckHoverState(normalized.length > 0);
setHoveredDeckPairMmsiSet((prev) => (equalNumberArrays(prev, normalized) ? prev : normalized)); setHoveredDeckPairMmsiSet((prev) => (equalNumberArrays(prev, normalized) ? prev : normalized));
mapDeckPairHoverRef.current = normalized; mapDeckPairHoverRef.current = normalized;
if (!equalNumberArrays(prev, normalized)) {
if (normalized.length > 0) {
mapDrivenPairHoverRef.current = true;
onHoverPair?.(normalized);
} else if (mapDrivenPairHoverRef.current && prev.length > 0) {
onClearPairHover?.();
mapDrivenPairHoverRef.current = false;
}
}
}, },
[setHoveredDeckPairMmsiSet, touchDeckHoverState], [setHoveredDeckPairMmsiSet, touchDeckHoverState, onHoverPair, onClearPairHover],
); );
const setMapFleetHoverState = useCallback( const setMapFleetHoverState = useCallback(
@ -352,8 +430,21 @@ export function Map3D({
setHoveredDeckFleetOwner(ownerKey); setHoveredDeckFleetOwner(ownerKey);
setHoveredDeckFleetMmsis(normalized); setHoveredDeckFleetMmsis(normalized);
mapFleetHoverStateRef.current = { ownerKey, vesselMmsis: normalized }; mapFleetHoverStateRef.current = { ownerKey, vesselMmsis: normalized };
if (ownerKey != null || normalized.length > 0) {
mapDrivenFleetHoverRef.current = true;
onHoverFleet?.(ownerKey, normalized);
} else if (mapDrivenFleetHoverRef.current) {
onClearFleetHover?.();
mapDrivenFleetHoverRef.current = false;
}
}, },
[setHoveredDeckFleetOwner, setHoveredDeckFleetMmsis, touchDeckHoverState], [
setHoveredDeckFleetOwner,
setHoveredDeckFleetMmsis,
touchDeckHoverState,
onHoverFleet,
onClearFleetHover,
],
); );
// hover RAF cleanup // hover RAF cleanup
@ -401,48 +492,48 @@ export function Map3D({
}, [pairLinks]); }, [pairLinks]);
const pairLinksInteractive = useMemo(() => { const pairLinksInteractive = useMemo(() => {
if (!overlays.pairLines || (pairLinks?.length ?? 0) === 0) return []; if ((pairLinks?.length ?? 0) === 0) return [];
if (hoveredPairMmsiSetRef.size < 2) return []; if (effectiveHoveredPairMmsiSet.size < 2) return [];
const links = pairLinks || []; const links = pairLinks || [];
return links.filter((link) => return links.filter((link) =>
hoveredPairMmsiSetRef.has(link.aMmsi) && hoveredPairMmsiSetRef.has(link.bMmsi), effectiveHoveredPairMmsiSet.has(link.aMmsi) && effectiveHoveredPairMmsiSet.has(link.bMmsi),
); );
}, [pairLinks, hoveredPairMmsiSetRef, overlays.pairLines]); }, [pairLinks, effectiveHoveredPairMmsiSet]);
const pairRangesInteractive = useMemo(() => { const pairRangesInteractive = useMemo(() => {
if (!overlays.pairRange || pairRanges.length === 0) return []; if (pairRanges.length === 0) return [];
if (hoveredPairMmsiSetRef.size < 2) return []; if (effectiveHoveredPairMmsiSet.size < 2) return [];
return pairRanges.filter((range) => return pairRanges.filter((range) =>
hoveredPairMmsiSetRef.has(range.aMmsi) && hoveredPairMmsiSetRef.has(range.bMmsi), effectiveHoveredPairMmsiSet.has(range.aMmsi) && effectiveHoveredPairMmsiSet.has(range.bMmsi),
); );
}, [pairRanges, hoveredPairMmsiSetRef, overlays.pairRange]); }, [pairRanges, effectiveHoveredPairMmsiSet]);
const fcLinesInteractive = useMemo(() => { const fcLinesInteractive = useMemo(() => {
if (!overlays.fcLines || fcDashed.length === 0) return []; if (fcDashed.length === 0) return [];
if (highlightedMmsiSetCombined.size === 0) return []; if (highlightedMmsiSetCombined.size === 0) return [];
return fcDashed.filter( return fcDashed.filter(
(line) => (line) =>
[line.fromMmsi, line.toMmsi].some((mmsi) => (mmsi == null ? false : highlightedMmsiSetCombined.has(mmsi))), [line.fromMmsi, line.toMmsi].some((mmsi) => (mmsi == null ? false : highlightedMmsiSetCombined.has(mmsi))),
); );
}, [fcDashed, overlays.fcLines, highlightedMmsiSetCombined]); }, [fcDashed, highlightedMmsiSetCombined]);
const fleetCirclesInteractive = useMemo(() => { const fleetCirclesInteractive = useMemo(() => {
if (!overlays.fleetCircles || (fleetCircles?.length ?? 0) === 0) return []; if ((fleetCircles?.length ?? 0) === 0) return [];
if (hoveredFleetOwnerKeys.size === 0 && highlightedMmsiSetCombined.size === 0) return []; if (hoveredFleetOwnerKeys.size === 0 && highlightedMmsiSetCombined.size === 0) return [];
const circles = fleetCircles || []; const circles = fleetCircles || [];
return circles.filter((item) => isHighlightedFleet(item.ownerKey, item.vesselMmsis)); return circles.filter((item) => isHighlightedFleet(item.ownerKey, item.vesselMmsis));
}, [fleetCircles, hoveredFleetOwnerKeys, isHighlightedFleet, overlays.fleetCircles, highlightedMmsiSetCombined]); }, [fleetCircles, hoveredFleetOwnerKeys, isHighlightedFleet, highlightedMmsiSetCombined]);
// ── Hook orchestration ─────────────────────────────────────────────── // ── Hook orchestration ───────────────────────────────────────────────
const { ensureMercatorOverlay, clearGlobeNativeLayers, pulseMapSync } = useMapInit( const { ensureMercatorOverlay, pulseMapSync } = useMapInit(
containerRef, mapRef, overlayRef, overlayInteractionRef, globeDeckLayerRef, containerRef, mapRef, overlayRef, overlayInteractionRef, globeDeckLayerRef,
baseMapRef, projectionRef, baseMapRef, projectionRef,
{ baseMap, projection, showSeamark: settings.showSeamark, onViewBboxChange, setMapSyncEpoch }, { baseMap, projection, showSeamark: settings.showSeamark, onViewBboxChange, setMapSyncEpoch, initialView, onViewStateChange },
); );
const reorderGlobeFeatureLayers = useProjectionToggle( const reorderGlobeFeatureLayers = useProjectionToggle(
mapRef, overlayRef, overlayInteractionRef, globeDeckLayerRef, projectionBusyRef, mapRef, overlayRef, overlayInteractionRef, globeDeckLayerRef, projectionBusyRef,
{ projection, clearGlobeNativeLayers, ensureMercatorOverlay, onProjectionLoadingChange, pulseMapSync, setMapSyncEpoch }, { projection, ensureMercatorOverlay, onProjectionLoadingChange, pulseMapSync, setMapSyncEpoch },
); );
useBaseMapToggle( useBaseMapToggle(
@ -468,10 +559,11 @@ export function Map3D({
useGlobeShips( useGlobeShips(
mapRef, projectionBusyRef, reorderGlobeFeatureLayers, mapRef, projectionBusyRef, reorderGlobeFeatureLayers,
{ {
projection, settings, shipData, shipHighlightSet, shipHoverOverlaySet, projection, settings, shipData: shipLayerData, shipHighlightSet, shipHoverOverlaySet,
shipOverlayLayerData, shipLayerData, shipByMmsi, mapSyncEpoch, shipOverlayLayerData, shipLayerData, shipByMmsi, mapSyncEpoch,
onSelectMmsi, onToggleHighlightMmsi, targets, overlays, onSelectMmsi, onToggleHighlightMmsi, targets: shipLayerData, overlays,
legacyHits, selectedMmsi, isBaseHighlightedMmsi, hasAuxiliarySelectModifier, legacyHits, selectedMmsi, isBaseHighlightedMmsi, hasAuxiliarySelectModifier,
onGlobeShipsReady,
}, },
); );
@ -496,7 +588,7 @@ export function Map3D({
useDeckLayers( useDeckLayers(
mapRef, overlayRef, globeDeckLayerRef, projectionBusyRef, mapRef, overlayRef, globeDeckLayerRef, projectionBusyRef,
{ {
projection, settings, shipLayerData, shipOverlayLayerData, shipData, projection, settings, trackReplayDeckLayers: trackReplayRenderState.trackReplayDeckLayers, shipLayerData, shipOverlayLayerData, shipData,
legacyHits, pairLinks, fcLinks, fcDashed, fleetCircles, pairRanges, legacyHits, pairLinks, fcLinks, fcDashed, fleetCircles, pairRanges,
pairLinksInteractive, pairRangesInteractive, fcLinesInteractive, fleetCirclesInteractive, pairLinksInteractive, pairRangesInteractive, fcLinesInteractive, fleetCirclesInteractive,
overlays, shipByMmsi, selectedMmsi, shipHighlightSet, overlays, shipByMmsi, selectedMmsi, shipHighlightSet,
@ -523,10 +615,88 @@ export function Map3D({
}, },
); );
useTrackReplayLayer(
mapRef, projectionBusyRef, reorderGlobeFeatureLayers,
{ activeTrack, projection, mapSyncEpoch, renderState: trackReplayRenderState },
);
// 우클릭 컨텍스트 메뉴 — 대상선박(legacyHits)만 허용
// Mercator: Deck.gl hover 상태에서 MMSI 참조, Globe: queryRenderedFeatures
const hoveredDeckMmsiRef = useRef(hoveredDeckMmsiArr);
useEffect(() => { hoveredDeckMmsiRef.current = hoveredDeckMmsiArr; }, [hoveredDeckMmsiArr]);
useEffect(() => {
const container = containerRef.current;
if (!container) return;
const onContextMenu = (e: MouseEvent) => {
e.preventDefault();
if (!onOpenTrackMenu) return;
const map = mapRef.current;
if (!map || !map.isStyleLoaded() || projectionBusyRef.current) return;
let mmsi: number | null = null;
if (projectionRef.current === 'globe') {
// Globe: MapLibre 네이티브 레이어에서 쿼리
const point: [number, number] = [e.offsetX, e.offsetY];
const shipLayerIds = [
'ships-globe', 'ships-globe-lite', 'ships-globe-halo', 'ships-globe-outline',
].filter((id) => map.getLayer(id));
let features: maplibregl.MapGeoJSONFeature[] = [];
try {
if (shipLayerIds.length > 0) {
features = map.queryRenderedFeatures(point, { layers: shipLayerIds });
}
} catch { /* ignore */ }
if (features.length > 0) {
const props = features[0].properties || {};
const raw = typeof props.mmsi === 'number' ? props.mmsi : Number(props.mmsi);
if (Number.isFinite(raw) && raw > 0) mmsi = raw;
}
} else {
// Mercator: Deck.gl hover 상태에서 현재 호버된 MMSI 사용
const hovered = hoveredDeckMmsiRef.current;
if (hovered.length > 0) mmsi = hovered[0];
}
if (mmsi == null || !legacyHits?.has(mmsi)) return;
const target = shipByMmsi.get(mmsi);
const vesselName = (target?.name || '').trim() || `MMSI ${mmsi}`;
onOpenTrackMenu({ x: e.clientX, y: e.clientY, mmsi, vesselName });
};
container.addEventListener('contextmenu', onContextMenu);
return () => container.removeEventListener('contextmenu', onContextMenu);
}, [onOpenTrackMenu, legacyHits, shipByMmsi]);
useFlyTo( useFlyTo(
mapRef, projectionRef, mapRef, projectionRef,
{ selectedMmsi, shipData, fleetFocusId, fleetFocusLon, fleetFocusLat, fleetFocusZoom }, { selectedMmsi, shipData, fleetFocusId, fleetFocusLon, fleetFocusLat, fleetFocusZoom },
); );
return <div ref={containerRef} style={{ width: '100%', height: '100%' }} />; // Map ready 콜백 — mapSyncEpoch 초기 증가 시 1회 호출
const mapReadyFiredRef = useRef(false);
useEffect(() => {
if (mapReadyFiredRef.current || !onMapReady || !mapRef.current) return;
mapReadyFiredRef.current = true;
onMapReady(mapRef.current);
}, [mapSyncEpoch, onMapReady]);
return (
<>
<div ref={containerRef} style={{ width: '100%', height: '100%' }} />
{trackContextMenu && onRequestTrack && onCloseTrackMenu && (
<VesselContextMenu
x={trackContextMenu.x}
y={trackContextMenu.y}
mmsi={trackContextMenu.mmsi}
vesselName={trackContextMenu.vesselName}
onRequestTrack={onRequestTrack}
onClose={onCloseTrackMenu}
/>
)}
</>
);
} }

파일 보기

@ -0,0 +1,135 @@
import { useEffect, useRef } from 'react';
interface Props {
x: number;
y: number;
mmsi: number;
vesselName: string;
onRequestTrack: (mmsi: number, minutes: number) => void;
onClose: () => void;
}
const TRACK_OPTIONS = [
{ label: '6시간', minutes: 360 },
{ label: '12시간', minutes: 720 },
{ label: '1일', minutes: 1440 },
{ label: '3일', minutes: 4320 },
{ label: '5일', minutes: 7200 },
] as const;
const MENU_WIDTH = 180;
const MENU_PAD = 8;
export function VesselContextMenu({ x, y, mmsi, vesselName, onRequestTrack, onClose }: Props) {
const ref = useRef<HTMLDivElement>(null);
// 화면 밖 보정
const left = Math.min(x, window.innerWidth - MENU_WIDTH - MENU_PAD);
const maxTop = window.innerHeight - (TRACK_OPTIONS.length * 30 + 56) - MENU_PAD;
const top = Math.min(y, maxTop);
useEffect(() => {
const onKey = (e: KeyboardEvent) => {
if (e.key === 'Escape') onClose();
};
const onClick = (e: MouseEvent) => {
if (ref.current && !ref.current.contains(e.target as Node)) onClose();
};
const onScroll = () => onClose();
window.addEventListener('keydown', onKey);
window.addEventListener('mousedown', onClick, true);
window.addEventListener('scroll', onScroll, true);
return () => {
window.removeEventListener('keydown', onKey);
window.removeEventListener('mousedown', onClick, true);
window.removeEventListener('scroll', onScroll, true);
};
}, [onClose]);
const handleSelect = (minutes: number) => {
onRequestTrack(mmsi, minutes);
onClose();
};
return (
<div
ref={ref}
style={{
position: 'fixed',
left,
top,
zIndex: 9999,
minWidth: MENU_WIDTH,
background: 'rgba(24, 24, 32, 0.96)',
border: '1px solid rgba(255,255,255,0.12)',
borderRadius: 8,
boxShadow: '0 8px 24px rgba(0,0,0,0.5)',
padding: '4px 0',
fontFamily: 'system-ui, sans-serif',
fontSize: 12,
color: '#e2e2e2',
backdropFilter: 'blur(8px)',
}}
>
{/* Header */}
<div
style={{
padding: '6px 12px 4px',
fontSize: 10,
fontWeight: 700,
color: 'rgba(255,255,255,0.45)',
letterSpacing: 0.3,
borderBottom: '1px solid rgba(255,255,255,0.06)',
marginBottom: 2,
whiteSpace: 'nowrap',
overflow: 'hidden',
textOverflow: 'ellipsis',
maxWidth: MENU_WIDTH - 24,
}}
title={`${vesselName} (${mmsi})`}
>
{vesselName}
</div>
{/* 항적조회 항목 */}
<div
style={{
padding: '4px 12px 2px',
fontSize: 11,
fontWeight: 600,
color: 'rgba(255,255,255,0.6)',
}}
>
</div>
{TRACK_OPTIONS.map((opt) => (
<button
key={opt.minutes}
onClick={() => handleSelect(opt.minutes)}
style={{
display: 'block',
width: '100%',
padding: '5px 12px 5px 24px',
background: 'none',
border: 'none',
color: '#e2e2e2',
fontSize: 12,
textAlign: 'left',
cursor: 'pointer',
lineHeight: 1.4,
}}
onMouseEnter={(e) => {
(e.target as HTMLElement).style.background = 'rgba(59,130,246,0.18)';
}}
onMouseLeave={(e) => {
(e.target as HTMLElement).style.background = 'none';
}}
>
{opt.label}
</button>
))}
</div>
);
}

파일 보기

@ -104,7 +104,7 @@ export const FLEET_RANGE_LINE_DECK: [number, number, number, number] = [
OVERLAY_FLEET_RANGE_RGB[0], OVERLAY_FLEET_RANGE_RGB[1], OVERLAY_FLEET_RANGE_RGB[2], 140, OVERLAY_FLEET_RANGE_RGB[0], OVERLAY_FLEET_RANGE_RGB[1], OVERLAY_FLEET_RANGE_RGB[2], 140,
]; ];
export const FLEET_RANGE_FILL_DECK: [number, number, number, number] = [ export const FLEET_RANGE_FILL_DECK: [number, number, number, number] = [
OVERLAY_FLEET_RANGE_RGB[0], OVERLAY_FLEET_RANGE_RGB[1], OVERLAY_FLEET_RANGE_RGB[2], 6, OVERLAY_FLEET_RANGE_RGB[0], OVERLAY_FLEET_RANGE_RGB[1], OVERLAY_FLEET_RANGE_RGB[2], 0,
]; ];
// ── Highlighted variants ── // ── Highlighted variants ──
@ -131,7 +131,7 @@ export const FLEET_RANGE_LINE_DECK_HL: [number, number, number, number] = [
OVERLAY_FLEET_RANGE_RGB[0], OVERLAY_FLEET_RANGE_RGB[1], OVERLAY_FLEET_RANGE_RGB[2], 220, OVERLAY_FLEET_RANGE_RGB[0], OVERLAY_FLEET_RANGE_RGB[1], OVERLAY_FLEET_RANGE_RGB[2], 220,
]; ];
export const FLEET_RANGE_FILL_DECK_HL: [number, number, number, number] = [ export const FLEET_RANGE_FILL_DECK_HL: [number, number, number, number] = [
OVERLAY_FLEET_RANGE_RGB[0], OVERLAY_FLEET_RANGE_RGB[1], OVERLAY_FLEET_RANGE_RGB[2], 42, OVERLAY_FLEET_RANGE_RGB[0], OVERLAY_FLEET_RANGE_RGB[1], OVERLAY_FLEET_RANGE_RGB[2], 120,
]; ];
// ── MapLibre overlay colors ── // ── MapLibre overlay colors ──
@ -151,8 +151,8 @@ export const FC_LINE_SUSPICIOUS_ML = rgbaCss(OVERLAY_SUSPICIOUS_RGB, 0.95);
export const FC_LINE_NORMAL_ML_HL = rgbaCss(OVERLAY_FC_TRANSFER_RGB, 0.98); export const FC_LINE_NORMAL_ML_HL = rgbaCss(OVERLAY_FC_TRANSFER_RGB, 0.98);
export const FC_LINE_SUSPICIOUS_ML_HL = rgbaCss(OVERLAY_SUSPICIOUS_RGB, 0.98); export const FC_LINE_SUSPICIOUS_ML_HL = rgbaCss(OVERLAY_SUSPICIOUS_RGB, 0.98);
export const FLEET_FILL_ML = rgbaCss(OVERLAY_FLEET_RANGE_RGB, 0.02); export const FLEET_FILL_ML = rgbaCss(OVERLAY_FLEET_RANGE_RGB, 0.12);
export const FLEET_FILL_ML_HL = rgbaCss(OVERLAY_FLEET_RANGE_RGB, 0.16); export const FLEET_FILL_ML_HL = rgbaCss(OVERLAY_FLEET_RANGE_RGB, 0.42);
export const FLEET_LINE_ML = rgbaCss(OVERLAY_FLEET_RANGE_RGB, 0.65); export const FLEET_LINE_ML = rgbaCss(OVERLAY_FLEET_RANGE_RGB, 0.65);
export const FLEET_LINE_ML_HL = rgbaCss(OVERLAY_FLEET_RANGE_RGB, 0.95); export const FLEET_LINE_ML_HL = rgbaCss(OVERLAY_FLEET_RANGE_RGB, 0.95);

파일 보기

@ -110,7 +110,7 @@ export function useBaseMapToggle(
if (!map) return; if (!map) return;
if (showSeamark) { if (showSeamark) {
try { try {
ensureSeamarkOverlay(map, 'bathymetry-lines'); ensureSeamarkOverlay(map, 'bathymetry-lines-coarse');
map.setPaintProperty('seamark', 'raster-opacity', 0.85); map.setPaintProperty('seamark', 'raster-opacity', 0.85);
} catch { } catch {
// ignore until style is ready // ignore until style is ready

파일 보기

@ -50,6 +50,13 @@ import {
getFleetCircleTooltipHtml, getFleetCircleTooltipHtml,
} from '../lib/tooltips'; } from '../lib/tooltips';
import { sanitizeDeckLayerList } from '../lib/mapCore'; import { sanitizeDeckLayerList } from '../lib/mapCore';
import { getCachedShipIcon } from '../lib/shipIconCache';
// NOTE:
// Globe mode now relies on MapLibre native overlays (useGlobeOverlays/useGlobeShips).
// Keep Deck custom-layer rendering disabled in globe to avoid projection-space artifacts.
const ENABLE_GLOBE_DECK_OVERLAYS = false;
export function useDeckLayers( export function useDeckLayers(
mapRef: MutableRefObject<import('maplibre-gl').Map | null>, mapRef: MutableRefObject<import('maplibre-gl').Map | null>,
@ -59,6 +66,7 @@ export function useDeckLayers(
opts: { opts: {
projection: MapProjectionId; projection: MapProjectionId;
settings: Map3DSettings; settings: Map3DSettings;
trackReplayDeckLayers: unknown[];
shipLayerData: AisTarget[]; shipLayerData: AisTarget[];
shipOverlayLayerData: AisTarget[]; shipOverlayLayerData: AisTarget[];
shipData: AisTarget[]; shipData: AisTarget[];
@ -96,7 +104,7 @@ export function useDeckLayers(
}, },
) { ) {
const { const {
projection, settings, shipLayerData, shipOverlayLayerData, shipData, projection, settings, trackReplayDeckLayers, shipLayerData, shipOverlayLayerData, shipData,
legacyHits, pairLinks, fcDashed, fleetCircles, pairRanges, legacyHits, pairLinks, fcDashed, fleetCircles, pairRanges,
pairLinksInteractive, pairRangesInteractive, fcLinesInteractive, fleetCirclesInteractive, pairLinksInteractive, pairRangesInteractive, fcLinesInteractive, fleetCirclesInteractive,
overlays, shipByMmsi, selectedMmsi, shipHighlightSet, overlays, shipByMmsi, selectedMmsi, shipHighlightSet,
@ -374,7 +382,7 @@ export function useDeckLayers(
pickable: true, pickable: true,
billboard: false, billboard: false,
parameters: overlayParams, parameters: overlayParams,
iconAtlas: '/assets/ship.svg', iconAtlas: getCachedShipIcon(),
iconMapping: SHIP_ICON_MAPPING, iconMapping: SHIP_ICON_MAPPING,
getIcon: () => 'ship', getIcon: () => 'ship',
getPosition: (d) => [d.lon, d.lat] as [number, number], getPosition: (d) => [d.lon, d.lat] as [number, number],
@ -397,7 +405,7 @@ export function useDeckLayers(
pickable: false, pickable: false,
billboard: false, billboard: false,
parameters: overlayParams, parameters: overlayParams,
iconAtlas: '/assets/ship.svg', iconAtlas: getCachedShipIcon(),
iconMapping: SHIP_ICON_MAPPING, iconMapping: SHIP_ICON_MAPPING,
getIcon: () => 'ship', getIcon: () => 'ship',
getPosition: (d) => [d.lon, d.lat] as [number, number], getPosition: (d) => [d.lon, d.lat] as [number, number],
@ -442,7 +450,7 @@ export function useDeckLayers(
pickable: true, pickable: true,
billboard: false, billboard: false,
parameters: overlayParams, parameters: overlayParams,
iconAtlas: '/assets/ship.svg', iconAtlas: getCachedShipIcon(),
iconMapping: SHIP_ICON_MAPPING, iconMapping: SHIP_ICON_MAPPING,
getIcon: () => 'ship', getIcon: () => 'ship',
getPosition: (d) => [d.lon, d.lat] as [number, number], getPosition: (d) => [d.lon, d.lat] as [number, number],
@ -458,16 +466,16 @@ export function useDeckLayers(
} }
} }
if (overlays.pairRange && pairRangesInteractive.length > 0) { if (pairRangesInteractive.length > 0) {
layers.push(new ScatterplotLayer<PairRangeCircle>({ id: 'pair-range-overlay', data: pairRangesInteractive, pickable: false, billboard: false, parameters: overlayParams, filled: false, stroked: true, radiusUnits: 'meters', getRadius: (d) => d.radiusNm * 1852, radiusMinPixels: 10, lineWidthUnits: 'pixels', getLineWidth: () => 2.2, getLineColor: (d) => (d.warn ? PAIR_RANGE_WARN_DECK_HL : PAIR_RANGE_NORMAL_DECK_HL), getPosition: (d) => d.center })); layers.push(new ScatterplotLayer<PairRangeCircle>({ id: 'pair-range-overlay', data: pairRangesInteractive, pickable: false, billboard: false, parameters: overlayParams, filled: false, stroked: true, radiusUnits: 'meters', getRadius: (d) => d.radiusNm * 1852, radiusMinPixels: 10, lineWidthUnits: 'pixels', getLineWidth: () => 2.2, getLineColor: (d) => (d.warn ? PAIR_RANGE_WARN_DECK_HL : PAIR_RANGE_NORMAL_DECK_HL), getPosition: (d) => d.center }));
} }
if (overlays.pairLines && pairLinksInteractive.length > 0) { if (pairLinksInteractive.length > 0) {
layers.push(new LineLayer<PairLink>({ id: 'pair-lines-overlay', data: pairLinksInteractive, pickable: false, parameters: overlayParams, getSourcePosition: (d) => d.from, getTargetPosition: (d) => d.to, getColor: (d) => (d.warn ? PAIR_LINE_WARN_DECK_HL : PAIR_LINE_NORMAL_DECK_HL), getWidth: () => 2.6, widthUnits: 'pixels' })); layers.push(new LineLayer<PairLink>({ id: 'pair-lines-overlay', data: pairLinksInteractive, pickable: false, parameters: overlayParams, getSourcePosition: (d) => d.from, getTargetPosition: (d) => d.to, getColor: (d) => (d.warn ? PAIR_LINE_WARN_DECK_HL : PAIR_LINE_NORMAL_DECK_HL), getWidth: () => 2.6, widthUnits: 'pixels' }));
} }
if (overlays.fcLines && fcLinesInteractive.length > 0) { if (fcLinesInteractive.length > 0) {
layers.push(new LineLayer<DashSeg>({ id: 'fc-lines-overlay', data: fcLinesInteractive, pickable: false, parameters: overlayParams, getSourcePosition: (d) => d.from, getTargetPosition: (d) => d.to, getColor: (d) => (d.suspicious ? FC_LINE_SUSPICIOUS_DECK_HL : FC_LINE_NORMAL_DECK_HL), getWidth: () => 1.9, widthUnits: 'pixels' })); layers.push(new LineLayer<DashSeg>({ id: 'fc-lines-overlay', data: fcLinesInteractive, pickable: false, parameters: overlayParams, getSourcePosition: (d) => d.from, getTargetPosition: (d) => d.to, getColor: (d) => (d.suspicious ? FC_LINE_SUSPICIOUS_DECK_HL : FC_LINE_NORMAL_DECK_HL), getWidth: () => 1.9, widthUnits: 'pixels' }));
} }
if (overlays.fleetCircles && fleetCirclesInteractive.length > 0) { if (fleetCirclesInteractive.length > 0) {
layers.push(new ScatterplotLayer<FleetCircle>({ id: 'fleet-circles-overlay-fill', data: fleetCirclesInteractive, pickable: false, billboard: false, parameters: overlayParams, filled: true, stroked: false, radiusUnits: 'meters', getRadius: (d) => d.radiusNm * 1852, getFillColor: () => FLEET_RANGE_FILL_DECK_HL })); layers.push(new ScatterplotLayer<FleetCircle>({ id: 'fleet-circles-overlay-fill', data: fleetCirclesInteractive, pickable: false, billboard: false, parameters: overlayParams, filled: true, stroked: false, radiusUnits: 'meters', getRadius: (d) => d.radiusNm * 1852, getFillColor: () => FLEET_RANGE_FILL_DECK_HL }));
layers.push(new ScatterplotLayer<FleetCircle>({ id: 'fleet-circles-overlay', data: fleetCirclesInteractive, pickable: false, billboard: false, parameters: overlayParams, filled: false, stroked: true, radiusUnits: 'meters', getRadius: (d) => d.radiusNm * 1852, lineWidthUnits: 'pixels', getLineWidth: () => 1.8, getLineColor: () => FLEET_RANGE_LINE_DECK_HL, getPosition: (d) => d.center })); layers.push(new ScatterplotLayer<FleetCircle>({ id: 'fleet-circles-overlay', data: fleetCirclesInteractive, pickable: false, billboard: false, parameters: overlayParams, filled: false, stroked: true, radiusUnits: 'meters', getRadius: (d) => d.radiusNm * 1852, lineWidthUnits: 'pixels', getLineWidth: () => 1.8, getLineColor: () => FLEET_RANGE_LINE_DECK_HL, getPosition: (d) => d.center }));
} }
@ -478,10 +486,12 @@ export function useDeckLayers(
if (settings.showShips && shipOverlayLayerData.filter((t) => legacyHits?.has(t.mmsi)).length > 0) { if (settings.showShips && shipOverlayLayerData.filter((t) => legacyHits?.has(t.mmsi)).length > 0) {
const shipOverlayTargetData2 = shipOverlayLayerData.filter((t) => legacyHits?.has(t.mmsi)); const shipOverlayTargetData2 = shipOverlayLayerData.filter((t) => legacyHits?.has(t.mmsi));
layers.push(new IconLayer<AisTarget>({ id: 'ships-overlay-target', data: shipOverlayTargetData2, pickable: false, billboard: false, parameters: overlayParams, iconAtlas: '/assets/ship.svg', iconMapping: SHIP_ICON_MAPPING, getIcon: () => 'ship', getPosition: (d) => [d.lon, d.lat] as [number, number], getAngle: (d) => -getDisplayHeading({ cog: d.cog, heading: d.heading }), sizeUnits: 'pixels', getSize: (d) => { if (selectedMmsi != null && d.mmsi === selectedMmsi) return FLAT_SHIP_ICON_SIZE_SELECTED; if (shipHighlightSet.has(d.mmsi)) return FLAT_SHIP_ICON_SIZE_HIGHLIGHTED; return 0; }, getColor: (d) => { if (!shipHighlightSet.has(d.mmsi) && !(selectedMmsi != null && d.mmsi === selectedMmsi)) return [0, 0, 0, 0]; return getShipColor(d, selectedMmsi, legacyHits?.get(d.mmsi)?.shipCode ?? null, shipHighlightSet); } })); layers.push(new IconLayer<AisTarget>({ id: 'ships-overlay-target', data: shipOverlayTargetData2, pickable: false, billboard: false, parameters: overlayParams, iconAtlas: getCachedShipIcon(), iconMapping: SHIP_ICON_MAPPING, getIcon: () => 'ship', getPosition: (d) => [d.lon, d.lat] as [number, number], getAngle: (d) => -getDisplayHeading({ cog: d.cog, heading: d.heading }), sizeUnits: 'pixels', getSize: (d) => { if (selectedMmsi != null && d.mmsi === selectedMmsi) return FLAT_SHIP_ICON_SIZE_SELECTED; if (shipHighlightSet.has(d.mmsi)) return FLAT_SHIP_ICON_SIZE_HIGHLIGHTED; return 0; }, getColor: (d) => { if (!shipHighlightSet.has(d.mmsi) && !(selectedMmsi != null && d.mmsi === selectedMmsi)) return [0, 0, 0, 0]; return getShipColor(d, selectedMmsi, legacyHits?.get(d.mmsi)?.shipCode ?? null, shipHighlightSet); } }));
} }
const normalizedLayers = sanitizeDeckLayerList(layers); const normalizedBaseLayers = sanitizeDeckLayerList(layers);
const normalizedTrackLayers = sanitizeDeckLayerList(trackReplayDeckLayers);
const normalizedLayers = sanitizeDeckLayerList([...normalizedBaseLayers, ...normalizedTrackLayers]);
const deckProps = { const deckProps = {
layers: normalizedLayers, layers: normalizedLayers,
getTooltip: (info: PickingInfo) => { getTooltip: (info: PickingInfo) => {
@ -546,12 +556,7 @@ export function useDeckLayers(
try { try {
deckTarget.setProps(deckProps as never); deckTarget.setProps(deckProps as never);
} catch (e) { } catch (e) {
console.error('Failed to apply base mercator deck props. Falling back to empty layer set.', e); console.error('Failed to apply base mercator deck props. Keeping previous layer set.', e);
try {
deckTarget.setProps({ ...deckProps, layers: [] as unknown[] } as never);
} catch {
// Ignore secondary failure.
}
} }
}, [ }, [
ensureMercatorOverlay, ensureMercatorOverlay,
@ -576,6 +581,7 @@ export function useDeckLayers(
overlays.fleetCircles, overlays.fleetCircles,
settings.showDensity, settings.showDensity,
settings.showShips, settings.showShips,
trackReplayDeckLayers,
onDeckSelectOrHighlight, onDeckSelectOrHighlight,
onSelectMmsi, onSelectMmsi,
onToggleHighlightMmsi, onToggleHighlightMmsi,
@ -595,6 +601,16 @@ export function useDeckLayers(
const deckTarget = globeDeckLayerRef.current; const deckTarget = globeDeckLayerRef.current;
if (!deckTarget) return; if (!deckTarget) return;
if (!ENABLE_GLOBE_DECK_OVERLAYS) {
try {
deckTarget.setProps({ layers: [], getTooltip: undefined, onClick: undefined } as never);
} catch {
// ignore
}
return;
}
const overlayParams = GLOBE_OVERLAY_PARAMS; const overlayParams = GLOBE_OVERLAY_PARAMS;
const globeLayers: unknown[] = []; const globeLayers: unknown[] = [];

파일 보기

@ -118,7 +118,7 @@ export function useGlobeInteraction(
}); });
} }
if (layerId === 'fleet-circles-ml' || layerId === 'fleet-circles-ml-fill') { if (layerId === 'fleet-circles-ml') {
return getFleetCircleTooltipHtml({ return getFleetCircleTooltipHtml({
ownerKey: String(props.ownerKey ?? ''), ownerKey: String(props.ownerKey ?? ''),
ownerLabel: String(props.ownerLabel ?? props.ownerKey ?? ''), ownerLabel: String(props.ownerLabel ?? props.ownerKey ?? ''),
@ -184,9 +184,9 @@ export function useGlobeInteraction(
let candidateLayerIds: string[] = []; let candidateLayerIds: string[] = [];
try { try {
candidateLayerIds = [ candidateLayerIds = [
'ships-globe', 'ships-globe-halo', 'ships-globe-outline', 'ships-globe', 'ships-globe-lite', 'ships-globe-halo', 'ships-globe-outline',
'pair-lines-ml', 'fc-lines-ml', 'pair-lines-ml', 'fc-lines-ml',
'fleet-circles-ml', 'fleet-circles-ml-fill', 'fleet-circles-ml',
'pair-range-ml', 'pair-range-ml',
'zones-fill', 'zones-line', 'zones-label', 'zones-fill', 'zones-line', 'zones-label',
].filter((id) => map.getLayer(id)); ].filter((id) => map.getLayer(id));
@ -211,9 +211,9 @@ export function useGlobeInteraction(
} }
const priority = [ const priority = [
'ships-globe', 'ships-globe-halo', 'ships-globe-outline', 'ships-globe', 'ships-globe-lite', 'ships-globe-halo', 'ships-globe-outline',
'pair-lines-ml', 'fc-lines-ml', 'pair-range-ml', 'pair-lines-ml', 'fc-lines-ml', 'pair-range-ml',
'fleet-circles-ml-fill', 'fleet-circles-ml', 'fleet-circles-ml',
'zones-fill', 'zones-line', 'zones-label', 'zones-fill', 'zones-line', 'zones-label',
]; ];
@ -229,10 +229,14 @@ export function useGlobeInteraction(
const layerId = first.layer?.id; const layerId = first.layer?.id;
const props = first.properties || {}; const props = first.properties || {};
const isShipLayer = layerId === 'ships-globe' || layerId === 'ships-globe-halo' || layerId === 'ships-globe-outline'; const isShipLayer =
layerId === 'ships-globe' ||
layerId === 'ships-globe-lite' ||
layerId === 'ships-globe-halo' ||
layerId === 'ships-globe-outline';
const isPairLayer = layerId === 'pair-lines-ml' || layerId === 'pair-range-ml'; const isPairLayer = layerId === 'pair-lines-ml' || layerId === 'pair-range-ml';
const isFcLayer = layerId === 'fc-lines-ml'; const isFcLayer = layerId === 'fc-lines-ml';
const isFleetLayer = layerId === 'fleet-circles-ml' || layerId === 'fleet-circles-ml-fill'; const isFleetLayer = layerId === 'fleet-circles-ml';
const isZoneLayer = layerId === 'zones-fill' || layerId === 'zones-line' || layerId === 'zones-label'; const isZoneLayer = layerId === 'zones-fill' || layerId === 'zones-line' || layerId === 'zones-label';
if (isShipLayer) { if (isShipLayer) {

파일 보기

@ -11,7 +11,7 @@ import {
PAIR_RANGE_NORMAL_ML_HL, PAIR_RANGE_WARN_ML_HL, PAIR_RANGE_NORMAL_ML_HL, PAIR_RANGE_WARN_ML_HL,
FC_LINE_NORMAL_ML, FC_LINE_SUSPICIOUS_ML, FC_LINE_NORMAL_ML, FC_LINE_SUSPICIOUS_ML,
FC_LINE_NORMAL_ML_HL, FC_LINE_SUSPICIOUS_ML_HL, FC_LINE_NORMAL_ML_HL, FC_LINE_SUSPICIOUS_ML_HL,
FLEET_FILL_ML, FLEET_FILL_ML_HL, FLEET_FILL_ML_HL,
FLEET_LINE_ML, FLEET_LINE_ML_HL, FLEET_LINE_ML, FLEET_LINE_ML_HL,
} from '../constants'; } from '../constants';
import { makeUniqueSorted } from '../lib/setUtils'; import { makeUniqueSorted } from '../lib/setUtils';
@ -28,6 +28,7 @@ import {
} from '../lib/mlExpressions'; } from '../lib/mlExpressions';
import { kickRepaint, onMapStyleReady } from '../lib/mapCore'; import { kickRepaint, onMapStyleReady } from '../lib/mapCore';
import { circleRingLngLat } from '../lib/geometry'; import { circleRingLngLat } from '../lib/geometry';
import { guardedSetVisibility } from '../lib/layerHelpers';
import { dashifyLine } from '../lib/dashifyLine'; import { dashifyLine } from '../lib/dashifyLine';
export function useGlobeOverlays( export function useGlobeOverlays(
@ -60,17 +61,14 @@ export function useGlobeOverlays(
const layerId = 'pair-lines-ml'; const layerId = 'pair-lines-ml';
const remove = () => { const remove = () => {
try { guardedSetVisibility(map, layerId, 'none');
if (map.getLayer(layerId)) map.setLayoutProperty(layerId, 'visibility', 'none');
} catch {
// ignore
}
}; };
const ensure = () => { const ensure = () => {
if (projectionBusyRef.current) return; if (projectionBusyRef.current) return;
if (!map.isStyleLoaded()) return; if (!map.isStyleLoaded()) return;
if (projection !== 'globe' || !overlays.pairLines || (pairLinks?.length ?? 0) === 0) { const pairHoverActive = hoveredPairMmsiList.length >= 2;
if (projection !== 'globe' || (!overlays.pairLines && !pairHoverActive) || (pairLinks?.length ?? 0) === 0) {
remove(); remove();
return; return;
} }
@ -132,11 +130,7 @@ export function useGlobeOverlays(
console.warn('Pair lines layer add failed:', e); console.warn('Pair lines layer add failed:', e);
} }
} else { } else {
try { guardedSetVisibility(map, layerId, 'visible');
map.setLayoutProperty(layerId, 'visibility', 'visible');
} catch {
// ignore
}
} }
reorderGlobeFeatureLayers(); reorderGlobeFeatureLayers();
@ -148,7 +142,7 @@ export function useGlobeOverlays(
return () => { return () => {
stop(); stop();
}; };
}, [projection, overlays.pairLines, pairLinks, mapSyncEpoch, reorderGlobeFeatureLayers]); }, [projection, overlays.pairLines, pairLinks, hoveredPairMmsiList, mapSyncEpoch, reorderGlobeFeatureLayers]);
// FC lines // FC lines
useEffect(() => { useEffect(() => {
@ -159,17 +153,15 @@ export function useGlobeOverlays(
const layerId = 'fc-lines-ml'; const layerId = 'fc-lines-ml';
const remove = () => { const remove = () => {
try { guardedSetVisibility(map, layerId, 'none');
if (map.getLayer(layerId)) map.setLayoutProperty(layerId, 'visibility', 'none');
} catch {
// ignore
}
}; };
const ensure = () => { const ensure = () => {
if (projectionBusyRef.current) return; if (projectionBusyRef.current) return;
if (!map.isStyleLoaded()) return; if (!map.isStyleLoaded()) return;
if (projection !== 'globe' || !overlays.fcLines) { const fleetAwarePairMmsiList = makeUniqueSorted([...hoveredPairMmsiList, ...hoveredFleetMmsiList]);
const fcHoverActive = fleetAwarePairMmsiList.length > 0;
if (projection !== 'globe' || (!overlays.fcLines && !fcHoverActive)) {
remove(); remove();
return; return;
} }
@ -235,11 +227,7 @@ export function useGlobeOverlays(
console.warn('FC lines layer add failed:', e); console.warn('FC lines layer add failed:', e);
} }
} else { } else {
try { guardedSetVisibility(map, layerId, 'visible');
map.setLayoutProperty(layerId, 'visibility', 'visible');
} catch {
// ignore
}
} }
reorderGlobeFeatureLayers(); reorderGlobeFeatureLayers();
@ -251,7 +239,15 @@ export function useGlobeOverlays(
return () => { return () => {
stop(); stop();
}; };
}, [projection, overlays.fcLines, fcLinks, mapSyncEpoch, reorderGlobeFeatureLayers]); }, [
projection,
overlays.fcLines,
fcLinks,
hoveredPairMmsiList,
hoveredFleetMmsiList,
mapSyncEpoch,
reorderGlobeFeatureLayers,
]);
// Fleet circles // Fleet circles
useEffect(() => { useEffect(() => {
@ -263,30 +259,31 @@ export function useGlobeOverlays(
const layerId = 'fleet-circles-ml'; const layerId = 'fleet-circles-ml';
const fillLayerId = 'fleet-circles-ml-fill'; const fillLayerId = 'fleet-circles-ml-fill';
// fill layer 제거됨 — globe tessellation에서 vertex 65535 초과 경고 원인
// 라인만으로 fleet circle 시각화 충분
const remove = () => { const remove = () => {
try { guardedSetVisibility(map, layerId, 'none');
if (map.getLayer(fillLayerId)) map.setLayoutProperty(fillLayerId, 'visibility', 'none'); guardedSetVisibility(map, fillLayerId, 'none');
} catch {
// ignore
}
try {
if (map.getLayer(layerId)) map.setLayoutProperty(layerId, 'visibility', 'none');
} catch {
// ignore
}
}; };
const ensure = () => { const ensure = () => {
if (projectionBusyRef.current) return; if (projectionBusyRef.current) return;
if (!map.isStyleLoaded()) return; if (!map.isStyleLoaded()) return;
if (projection !== 'globe' || !overlays.fleetCircles || (fleetCircles?.length ?? 0) === 0) { const fleetHoverActive = hoveredFleetOwnerKeyList.length > 0 || hoveredFleetMmsiList.length > 0;
if (projection !== 'globe' || (!overlays.fleetCircles && !fleetHoverActive) || (fleetCircles?.length ?? 0) === 0) {
remove(); remove();
return; return;
} }
const circles = fleetCircles || [];
const isHighlightedFleet = (ownerKey: string, vesselMmsis: number[]) =>
hoveredFleetOwnerKeyList.includes(ownerKey) ||
(hoveredFleetMmsiList.length > 0 && vesselMmsis.some((mmsi) => hoveredFleetMmsiList.includes(mmsi)));
const fcLine: GeoJSON.FeatureCollection<GeoJSON.LineString> = { const fcLine: GeoJSON.FeatureCollection<GeoJSON.LineString> = {
type: 'FeatureCollection', type: 'FeatureCollection',
features: (fleetCircles || []).map((c) => { features: circles.map((c) => {
const ring = circleRingLngLat(c.center, c.radiusNm * 1852); const ring = circleRingLngLat(c.center, c.radiusNm * 1852);
return { return {
type: 'Feature', type: 'Feature',
@ -306,22 +303,19 @@ export function useGlobeOverlays(
const fcFill: GeoJSON.FeatureCollection<GeoJSON.Polygon> = { const fcFill: GeoJSON.FeatureCollection<GeoJSON.Polygon> = {
type: 'FeatureCollection', type: 'FeatureCollection',
features: (fleetCircles || []).map((c) => { features: circles
const ring = circleRingLngLat(c.center, c.radiusNm * 1852); .filter((c) => isHighlightedFleet(c.ownerKey, c.vesselMmsis))
return { .map((c) => ({
type: 'Feature', type: 'Feature',
id: `${makeFleetCircleFeatureId(c.ownerKey)}-fill`, id: makeFleetCircleFeatureId(`${c.ownerKey}-fill`),
geometry: { type: 'Polygon', coordinates: [ring] }, geometry: {
properties: { type: 'Polygon',
type: 'fleet-fill', coordinates: [circleRingLngLat(c.center, c.radiusNm * 1852, 24)],
ownerKey: c.ownerKey,
ownerLabel: c.ownerLabel,
count: c.count,
vesselMmsis: c.vesselMmsis,
highlighted: 0,
}, },
}; properties: {
}), ownerKey: c.ownerKey,
},
})),
}; };
try { try {
@ -338,34 +332,7 @@ export function useGlobeOverlays(
if (existingFill) existingFill.setData(fcFill); if (existingFill) existingFill.setData(fcFill);
else map.addSource(fillSrcId, { type: 'geojson', data: fcFill } as GeoJSONSourceSpecification); else map.addSource(fillSrcId, { type: 'geojson', data: fcFill } as GeoJSONSourceSpecification);
} catch (e) { } catch (e) {
console.warn('Fleet circles source setup failed:', e); console.warn('Fleet circles fill source setup failed:', e);
return;
}
if (!map.getLayer(fillLayerId)) {
try {
map.addLayer(
{
id: fillLayerId,
type: 'fill',
source: fillSrcId,
layout: { visibility: 'visible' },
paint: {
'fill-color': ['case', ['==', ['get', 'highlighted'], 1], FLEET_FILL_ML_HL, FLEET_FILL_ML] as never,
'fill-opacity': ['case', ['==', ['get', 'highlighted'], 1], 0.7, 0.36] as never,
},
} as unknown as LayerSpecification,
undefined,
);
} catch (e) {
console.warn('Fleet circles fill layer add failed:', e);
}
} else {
try {
map.setLayoutProperty(fillLayerId, 'visibility', 'visible');
} catch {
// ignore
}
} }
if (!map.getLayer(layerId)) { if (!map.getLayer(layerId)) {
@ -388,11 +355,28 @@ export function useGlobeOverlays(
console.warn('Fleet circles layer add failed:', e); console.warn('Fleet circles layer add failed:', e);
} }
} else { } else {
try { guardedSetVisibility(map, layerId, 'visible');
map.setLayoutProperty(layerId, 'visibility', 'visible');
} catch {
// ignore
} }
if (!map.getLayer(fillLayerId)) {
try {
map.addLayer(
{
id: fillLayerId,
type: 'fill',
source: fillSrcId,
layout: { visibility: fcFill.features.length > 0 ? 'visible' : 'none' },
paint: {
'fill-color': FLEET_FILL_ML_HL,
},
} as unknown as LayerSpecification,
undefined,
);
} catch (e) {
console.warn('Fleet circles fill layer add failed:', e);
}
} else {
guardedSetVisibility(map, fillLayerId, fcFill.features.length > 0 ? 'visible' : 'none');
} }
reorderGlobeFeatureLayers(); reorderGlobeFeatureLayers();
@ -404,7 +388,15 @@ export function useGlobeOverlays(
return () => { return () => {
stop(); stop();
}; };
}, [projection, overlays.fleetCircles, fleetCircles, mapSyncEpoch, reorderGlobeFeatureLayers]); }, [
projection,
overlays.fleetCircles,
fleetCircles,
hoveredFleetOwnerKeyList,
hoveredFleetMmsiList,
mapSyncEpoch,
reorderGlobeFeatureLayers,
]);
// Pair range // Pair range
useEffect(() => { useEffect(() => {
@ -415,17 +407,14 @@ export function useGlobeOverlays(
const layerId = 'pair-range-ml'; const layerId = 'pair-range-ml';
const remove = () => { const remove = () => {
try { guardedSetVisibility(map, layerId, 'none');
if (map.getLayer(layerId)) map.setLayoutProperty(layerId, 'visibility', 'none');
} catch {
// ignore
}
}; };
const ensure = () => { const ensure = () => {
if (projectionBusyRef.current) return; if (projectionBusyRef.current) return;
if (!map.isStyleLoaded()) return; if (!map.isStyleLoaded()) return;
if (projection !== 'globe' || !overlays.pairRange) { const pairHoverActive = hoveredPairMmsiList.length >= 2;
if (projection !== 'globe' || (!overlays.pairRange && !pairHoverActive)) {
remove(); remove();
return; return;
} }
@ -503,11 +492,7 @@ export function useGlobeOverlays(
console.warn('Pair range layer add failed:', e); console.warn('Pair range layer add failed:', e);
} }
} else { } else {
try { guardedSetVisibility(map, layerId, 'visible');
map.setLayoutProperty(layerId, 'visibility', 'visible');
} catch {
// ignore
}
} }
kickRepaint(map); kickRepaint(map);
@ -518,7 +503,7 @@ export function useGlobeOverlays(
return () => { return () => {
stop(); stop();
}; };
}, [projection, overlays.pairRange, pairLinks, mapSyncEpoch, reorderGlobeFeatureLayers]); }, [projection, overlays.pairRange, pairLinks, hoveredPairMmsiList, mapSyncEpoch, reorderGlobeFeatureLayers]);
// Paint state updates for hover highlights // Paint state updates for hover highlights
// eslint-disable-next-line react-hooks/preserve-manual-memoization // eslint-disable-next-line react-hooks/preserve-manual-memoization
@ -593,10 +578,7 @@ export function useGlobeOverlays(
} }
try { try {
if (map.getLayer('fleet-circles-ml-fill')) { // fleet-circles-ml-fill 제거됨 (vertex 65535 경고 원인)
map.setPaintProperty('fleet-circles-ml-fill', 'fill-color', ['case', fleetHighlightExpr, FLEET_FILL_ML_HL, FLEET_FILL_ML] as never);
map.setPaintProperty('fleet-circles-ml-fill', 'fill-opacity', ['case', fleetHighlightExpr, 0.7, 0.28] as never);
}
if (map.getLayer('fleet-circles-ml')) { if (map.getLayer('fleet-circles-ml')) {
map.setPaintProperty('fleet-circles-ml', 'line-color', ['case', fleetHighlightExpr, FLEET_LINE_ML_HL, FLEET_LINE_ML] as never); map.setPaintProperty('fleet-circles-ml', 'line-color', ['case', fleetHighlightExpr, FLEET_LINE_ML_HL, FLEET_LINE_ML] as never);
map.setPaintProperty('fleet-circles-ml', 'line-width', ['case', fleetHighlightExpr, 2, 1.1] as never); map.setPaintProperty('fleet-circles-ml', 'line-width', ['case', fleetHighlightExpr, 2, 1.1] as never);

파일 보기

@ -1,4 +1,4 @@
import { useEffect, useRef, type MutableRefObject } from 'react'; import { useEffect, useMemo, useRef, type MutableRefObject } from 'react';
import type maplibregl from 'maplibre-gl'; import type maplibregl from 'maplibre-gl';
import type { GeoJSONSource, GeoJSONSourceSpecification, LayerSpecification } from 'maplibre-gl'; import type { GeoJSONSource, GeoJSONSourceSpecification, LayerSpecification } from 'maplibre-gl';
import type { AisTarget } from '../../../entities/aisTarget/model/types'; import type { AisTarget } from '../../../entities/aisTarget/model/types';
@ -25,6 +25,7 @@ import {
ensureFallbackShipImage, ensureFallbackShipImage,
} from '../lib/globeShipIcon'; } from '../lib/globeShipIcon';
import { clampNumber } from '../lib/geometry'; import { clampNumber } from '../lib/geometry';
import { guardedSetVisibility } from '../lib/layerHelpers';
export function useGlobeShips( export function useGlobeShips(
mapRef: MutableRefObject<maplibregl.Map | null>, mapRef: MutableRefObject<maplibregl.Map | null>,
@ -48,18 +49,81 @@ export function useGlobeShips(
selectedMmsi: number | null; selectedMmsi: number | null;
isBaseHighlightedMmsi: (mmsi: number) => boolean; isBaseHighlightedMmsi: (mmsi: number) => boolean;
hasAuxiliarySelectModifier: (ev?: { shiftKey?: boolean; ctrlKey?: boolean; metaKey?: boolean } | null) => boolean; hasAuxiliarySelectModifier: (ev?: { shiftKey?: boolean; ctrlKey?: boolean; metaKey?: boolean } | null) => boolean;
onGlobeShipsReady?: (ready: boolean) => void;
}, },
) { ) {
const { const {
projection, settings, shipData, shipHighlightSet, shipHoverOverlaySet, projection, settings, shipData, shipHighlightSet, shipHoverOverlaySet,
shipLayerData, mapSyncEpoch, onSelectMmsi, onToggleHighlightMmsi, targets, shipLayerData, mapSyncEpoch, onSelectMmsi, onToggleHighlightMmsi, targets,
overlays, legacyHits, selectedMmsi, isBaseHighlightedMmsi, hasAuxiliarySelectModifier, overlays, legacyHits, selectedMmsi, isBaseHighlightedMmsi, hasAuxiliarySelectModifier,
onGlobeShipsReady,
} = opts; } = opts;
const globeShipsEpochRef = useRef(-1); const globeShipsEpochRef = useRef(-1);
const globeShipIconLoadingRef = useRef(false);
const globeHoverShipSignatureRef = useRef(''); const globeHoverShipSignatureRef = useRef('');
// Globe GeoJSON을 projection과 무관하게 항상 사전 계산
// Globe 전환 시 즉시 사용 가능하도록 useMemo로 캐싱
const globeShipGeoJson = useMemo((): GeoJSON.FeatureCollection<GeoJSON.Point> => {
return {
type: 'FeatureCollection',
features: shipData.map((t) => {
const legacy = legacyHits?.get(t.mmsi) ?? null;
const labelName = legacy?.shipNameCn || legacy?.shipNameRoman || t.name || '';
const heading = getDisplayHeading({
cog: t.cog,
heading: t.heading,
offset: GLOBE_ICON_HEADING_OFFSET_DEG,
});
const isAnchored = isAnchoredShip({ sog: t.sog, cog: t.cog, heading: t.heading });
const shipHeading = isAnchored ? 0 : heading;
const hull = clampNumber(
(isFiniteNumber(t.length) ? t.length : 0) + (isFiniteNumber(t.width) ? t.width : 0) * 3,
50, 420,
);
const sizeScale = clampNumber(0.85 + (hull - 50) / 420, 0.82, 1.85);
const selected = t.mmsi === selectedMmsi;
const highlighted = isBaseHighlightedMmsi(t.mmsi);
const selectedScale = selected ? 1.08 : 1;
const highlightScale = highlighted ? 1.06 : 1;
const iconScale = selected ? selectedScale : highlightScale;
const iconSize3 = clampNumber(0.35 * sizeScale * selectedScale, 0.25, 1.3);
const iconSize7 = clampNumber(0.45 * sizeScale * selectedScale, 0.3, 1.45);
const iconSize10 = clampNumber(0.58 * sizeScale * selectedScale, 0.35, 1.8);
const iconSize14 = clampNumber(0.85 * sizeScale * selectedScale, 0.45, 2.6);
const iconSize18 = clampNumber(2.5 * sizeScale * selectedScale, 1.0, 6.0);
return {
type: 'Feature' as const,
...(isFiniteNumber(t.mmsi) ? { id: Math.trunc(t.mmsi) } : {}),
geometry: { type: 'Point' as const, coordinates: [t.lon, t.lat] },
properties: {
mmsi: t.mmsi,
name: t.name || '',
labelName,
cog: shipHeading,
heading: shipHeading,
sog: isFiniteNumber(t.sog) ? t.sog : 0,
isAnchored: isAnchored ? 1 : 0,
shipColor: getGlobeBaseShipColor({
legacy: legacy?.shipCode || null,
sog: isFiniteNumber(t.sog) ? t.sog : null,
}),
iconSize3: iconSize3 * iconScale,
iconSize7: iconSize7 * iconScale,
iconSize10: iconSize10 * iconScale,
iconSize14: iconSize14 * iconScale,
iconSize18: iconSize18 * iconScale,
sizeScale,
selected: selected ? 1 : 0,
highlighted: highlighted ? 1 : 0,
permitted: legacy ? 1 : 0,
code: legacy?.shipCode || '',
},
};
}),
};
}, [shipData, legacyHits, selectedMmsi, isBaseHighlightedMmsi]);
// Ship name labels in mercator // Ship name labels in mercator
useEffect(() => { useEffect(() => {
const map = mapRef.current; const map = mapRef.current;
@ -206,113 +270,58 @@ export function useGlobeShips(
const srcId = 'ships-globe-src'; const srcId = 'ships-globe-src';
const haloId = 'ships-globe-halo'; const haloId = 'ships-globe-halo';
const outlineId = 'ships-globe-outline'; const outlineId = 'ships-globe-outline';
const symbolLiteId = 'ships-globe-lite';
const symbolId = 'ships-globe'; const symbolId = 'ships-globe';
const labelId = 'ships-globe-label'; const labelId = 'ships-globe-label';
const remove = () => { // 레이어를 제거하지 않고 visibility만 'none'으로 설정
for (const id of [labelId, symbolId, outlineId, haloId]) { // guardedSetVisibility로 현재 값과 동일하면 호출 생략 (style._changed 방지)
try { const hide = () => {
if (map.getLayer(id)) map.removeLayer(id); for (const id of [labelId, symbolId, symbolLiteId, outlineId, haloId]) {
} catch { guardedSetVisibility(map, id, 'none');
// ignore
} }
}
try {
if (map.getSource(srcId)) map.removeSource(srcId);
} catch {
// ignore
}
globeHoverShipSignatureRef.current = '';
reorderGlobeFeatureLayers();
kickRepaint(map);
}; };
const ensureImage = () => { const ensureImage = () => {
ensureFallbackShipImage(map, imgId); ensureFallbackShipImage(map, imgId);
ensureFallbackShipImage(map, anchoredImgId, buildFallbackGlobeAnchoredShipIcon); ensureFallbackShipImage(map, anchoredImgId, buildFallbackGlobeAnchoredShipIcon);
if (globeShipIconLoadingRef.current) return;
if (map.hasImage(imgId) && map.hasImage(anchoredImgId)) return; if (map.hasImage(imgId) && map.hasImage(anchoredImgId)) return;
const addFallbackImage = () => {
ensureFallbackShipImage(map, imgId);
ensureFallbackShipImage(map, anchoredImgId, buildFallbackGlobeAnchoredShipIcon);
kickRepaint(map); kickRepaint(map);
}; };
let fallbackTimer: ReturnType<typeof window.setTimeout> | null = null;
try {
globeShipIconLoadingRef.current = true;
fallbackTimer = window.setTimeout(() => {
addFallbackImage();
}, 80);
void map
.loadImage('/assets/ship.svg')
.then((response) => {
globeShipIconLoadingRef.current = false;
if (fallbackTimer != null) {
clearTimeout(fallbackTimer);
fallbackTimer = null;
}
const loadedImage = (response as { data?: HTMLImageElement | ImageBitmap } | undefined)?.data;
if (!loadedImage) {
addFallbackImage();
return;
}
try {
if (map.hasImage(imgId)) {
try {
map.removeImage(imgId);
} catch {
// ignore
}
}
if (map.hasImage(anchoredImgId)) {
try {
map.removeImage(anchoredImgId);
} catch {
// ignore
}
}
map.addImage(imgId, loadedImage, { pixelRatio: 2, sdf: true });
map.addImage(anchoredImgId, loadedImage, { pixelRatio: 2, sdf: true });
kickRepaint(map);
} catch (e) {
console.warn('Ship icon image add failed:', e);
}
})
.catch(() => {
globeShipIconLoadingRef.current = false;
if (fallbackTimer != null) {
clearTimeout(fallbackTimer);
fallbackTimer = null;
}
addFallbackImage();
});
} catch (e) {
globeShipIconLoadingRef.current = false;
if (fallbackTimer != null) {
clearTimeout(fallbackTimer);
fallbackTimer = null;
}
try {
addFallbackImage();
} catch (fallbackError) {
console.warn('Ship icon image setup failed:', e, fallbackError);
}
}
};
const ensure = () => { const ensure = () => {
if (projectionBusyRef.current) return; if (!settings.showShips) {
if (!map.isStyleLoaded()) return; hide();
onGlobeShipsReady?.(false);
if (projection !== 'globe' || !settings.showShips) {
remove();
return; return;
} }
// 빠른 visibility 토글 — projectionBusy 중에도 실행
// guardedSetVisibility로 값이 실제로 바뀔 때만 setLayoutProperty 호출
// → style._changed 방지 → 불필요한 symbol placement 재계산 방지 → 라벨 사라짐 방지
const visibility: 'visible' | 'none' = projection === 'globe' ? 'visible' : 'none';
const labelVisibility: 'visible' | 'none' = projection === 'globe' && overlays.shipLabels ? 'visible' : 'none';
if (map.getLayer(symbolId) || map.getLayer(symbolLiteId)) {
const changed =
map.getLayoutProperty(symbolId, 'visibility') !== visibility ||
map.getLayoutProperty(symbolLiteId, 'visibility') !== visibility;
if (changed) {
for (const id of [haloId, outlineId, symbolLiteId, symbolId]) {
guardedSetVisibility(map, id, visibility);
}
if (projection === 'globe') kickRepaint(map);
}
guardedSetVisibility(map, labelId, labelVisibility);
}
// 데이터 업데이트는 projectionBusy 중에는 차단
if (projectionBusyRef.current) {
// 레이어가 이미 존재하면 ready 상태 유지
if (map.getLayer(symbolId)) onGlobeShipsReady?.(true);
return;
}
if (!map.isStyleLoaded()) return;
if (globeShipsEpochRef.current !== mapSyncEpoch) { if (globeShipsEpochRef.current !== mapSyncEpoch) {
globeShipsEpochRef.current = mapSyncEpoch; globeShipsEpochRef.current = mapSyncEpoch;
} }
@ -323,69 +332,8 @@ export function useGlobeShips(
console.warn('Ship icon image setup failed:', e); console.warn('Ship icon image setup failed:', e);
} }
const globeShipData = shipData; // 사전 계산된 GeoJSON 사용 (useMemo에서 projection과 무관하게 항상 준비됨)
const geojson: GeoJSON.FeatureCollection<GeoJSON.Point> = { const geojson = globeShipGeoJson;
type: 'FeatureCollection',
features: globeShipData.map((t) => {
const legacy = legacyHits?.get(t.mmsi) ?? null;
const labelName =
legacy?.shipNameCn ||
legacy?.shipNameRoman ||
t.name ||
'';
const heading = getDisplayHeading({
cog: t.cog,
heading: t.heading,
offset: GLOBE_ICON_HEADING_OFFSET_DEG,
});
const isAnchored = isAnchoredShip({
sog: t.sog,
cog: t.cog,
heading: t.heading,
});
const shipHeading = isAnchored ? 0 : heading;
const hull = clampNumber((isFiniteNumber(t.length) ? t.length : 0) + (isFiniteNumber(t.width) ? t.width : 0) * 3, 50, 420);
const sizeScale = clampNumber(0.85 + (hull - 50) / 420, 0.82, 1.85);
const selected = t.mmsi === selectedMmsi;
const highlighted = isBaseHighlightedMmsi(t.mmsi);
const selectedScale = selected ? 1.08 : 1;
const highlightScale = highlighted ? 1.06 : 1;
const iconScale = selected ? selectedScale : highlightScale;
const iconSize3 = clampNumber(0.35 * sizeScale * selectedScale, 0.25, 1.3);
const iconSize7 = clampNumber(0.45 * sizeScale * selectedScale, 0.3, 1.45);
const iconSize10 = clampNumber(0.58 * sizeScale * selectedScale, 0.35, 1.8);
const iconSize14 = clampNumber(0.85 * sizeScale * selectedScale, 0.45, 2.6);
const iconSize18 = clampNumber(2.5 * sizeScale * selectedScale, 1.0, 6.0);
return {
type: 'Feature',
...(isFiniteNumber(t.mmsi) ? { id: Math.trunc(t.mmsi) } : {}),
geometry: { type: 'Point', coordinates: [t.lon, t.lat] },
properties: {
mmsi: t.mmsi,
name: t.name || '',
labelName,
cog: shipHeading,
heading: shipHeading,
sog: isFiniteNumber(t.sog) ? t.sog : 0,
isAnchored: isAnchored ? 1 : 0,
shipColor: getGlobeBaseShipColor({
legacy: legacy?.shipCode || null,
sog: isFiniteNumber(t.sog) ? t.sog : null,
}),
iconSize3: iconSize3 * iconScale,
iconSize7: iconSize7 * iconScale,
iconSize10: iconSize10 * iconScale,
iconSize14: iconSize14 * iconScale,
iconSize18: iconSize18 * iconScale,
sizeScale,
selected: selected ? 1 : 0,
highlighted: highlighted ? 1 : 0,
permitted: legacy ? 1 : 0,
code: legacy?.shipCode || '',
},
};
}),
};
try { try {
const existing = map.getSource(srcId) as GeoJSONSource | undefined; const existing = map.getSource(srcId) as GeoJSONSource | undefined;
@ -396,8 +344,19 @@ export function useGlobeShips(
return; return;
} }
const visibility = settings.showShips ? 'visible' : 'none';
const before = undefined; const before = undefined;
const priorityFilter = [
'any',
['==', ['to-number', ['get', 'permitted'], 0], 1],
['==', ['to-number', ['get', 'selected'], 0], 1],
['==', ['to-number', ['get', 'highlighted'], 0], 1],
] as unknown as unknown[];
const nonPriorityFilter = [
'all',
['==', ['to-number', ['get', 'permitted'], 0], 0],
['==', ['to-number', ['get', 'selected'], 0], 0],
['==', ['to-number', ['get', 'highlighted'], 0], 0],
] as unknown as unknown[];
if (!map.getLayer(haloId)) { if (!map.getLayer(haloId)) {
try { try {
@ -434,35 +393,8 @@ export function useGlobeShips(
} catch (e) { } catch (e) {
console.warn('Ship halo layer add failed:', e); console.warn('Ship halo layer add failed:', e);
} }
} else {
try {
map.setLayoutProperty(haloId, 'visibility', visibility);
map.setLayoutProperty(haloId, 'circle-sort-key', [
'case',
['all', ['==', ['get', 'selected'], 1], ['==', ['get', 'permitted'], 1]], 120,
['all', ['==', ['get', 'highlighted'], 1], ['==', ['get', 'permitted'], 1]], 115,
['==', ['get', 'permitted'], 1], 110,
['==', ['get', 'selected'], 1], 60,
['==', ['get', 'highlighted'], 1], 55,
20,
] as never);
map.setPaintProperty(haloId, 'circle-color', [
'case',
['==', ['get', 'selected'], 1], 'rgba(14,234,255,1)',
['==', ['get', 'highlighted'], 1], 'rgba(245,158,11,1)',
['coalesce', ['get', 'shipColor'], '#64748b'],
] as never);
map.setPaintProperty(haloId, 'circle-opacity', [
'case',
['==', ['get', 'selected'], 1], 0.38,
['==', ['get', 'highlighted'], 1], 0.34,
0.16,
] as never);
map.setPaintProperty(haloId, 'circle-radius', GLOBE_SHIP_CIRCLE_RADIUS_EXPR);
} catch {
// ignore
}
} }
// halo: data-driven expressions are static — visibility handled by fast toggle above
if (!map.getLayer(outlineId)) { if (!map.getLayer(outlineId)) {
try { try {
@ -508,36 +440,78 @@ export function useGlobeShips(
} catch (e) { } catch (e) {
console.warn('Ship outline layer add failed:', e); console.warn('Ship outline layer add failed:', e);
} }
} else { }
// outline: data-driven expressions are static — visibility handled by fast toggle
if (!map.getLayer(symbolLiteId)) {
try { try {
map.setLayoutProperty(outlineId, 'visibility', visibility); map.addLayer(
map.setLayoutProperty(outlineId, 'circle-sort-key', [ {
id: symbolLiteId,
type: 'symbol',
source: srcId,
minzoom: 6.5,
filter: nonPriorityFilter as never,
layout: {
visibility,
'symbol-sort-key': 40 as never,
'icon-image': [
'case', 'case',
['all', ['==', ['get', 'selected'], 1], ['==', ['get', 'permitted'], 1]], 130, ['==', ['to-number', ['get', 'isAnchored'], 0], 1],
['all', ['==', ['get', 'highlighted'], 1], ['==', ['get', 'permitted'], 1]], 125, anchoredImgId,
['==', ['get', 'permitted'], 1], 120, imgId,
['==', ['get', 'selected'], 1], 70, ] as never,
['==', ['get', 'highlighted'], 1], 65, 'icon-size': [
30, 'interpolate',
] as never); ['linear'],
map.setPaintProperty(outlineId, 'circle-stroke-color', [ ['zoom'],
6.5,
['*', ['to-number', ['get', 'iconSize7'], 0.45], 0.45],
8,
['*', ['to-number', ['get', 'iconSize7'], 0.45], 0.62],
10,
['*', ['to-number', ['get', 'iconSize10'], 0.58], 0.72],
14,
['*', ['to-number', ['get', 'iconSize14'], 0.85], 0.78],
18,
['*', ['to-number', ['get', 'iconSize18'], 2.5], 0.78],
] as unknown as number[],
'icon-allow-overlap': true,
'icon-ignore-placement': true,
'icon-anchor': 'center',
'icon-rotate': [
'case', 'case',
['==', ['get', 'selected'], 1], 'rgba(14,234,255,0.95)', ['==', ['to-number', ['get', 'isAnchored'], 0], 1],
['==', ['get', 'highlighted'], 1], 'rgba(245,158,11,0.95)', 0,
['==', ['get', 'permitted'], 1], GLOBE_OUTLINE_PERMITTED, ['to-number', ['get', 'heading'], 0],
GLOBE_OUTLINE_OTHER, ] as never,
] as never); 'icon-rotation-alignment': 'map',
map.setPaintProperty(outlineId, 'circle-stroke-width', [ 'icon-pitch-alignment': 'map',
'case', },
['==', ['get', 'selected'], 1], 3.4, paint: {
['==', ['get', 'highlighted'], 1], 2.7, 'icon-color': ['coalesce', ['get', 'shipColor'], '#64748b'] as never,
['==', ['get', 'permitted'], 1], 1.8, 'icon-opacity': [
0.7, 'interpolate',
] as never); ['linear'],
} catch { ['zoom'],
// ignore 6.5,
0.16,
8,
0.34,
11,
0.54,
14,
0.68,
] as never,
},
} as unknown as LayerSpecification,
before,
);
} catch (e) {
console.warn('Ship lite symbol layer add failed:', e);
} }
} }
// lite symbol: lower LOD for non-priority vessels in low zoom
if (!map.getLayer(symbolId)) { if (!map.getLayer(symbolId)) {
try { try {
@ -546,6 +520,7 @@ export function useGlobeShips(
id: symbolId, id: symbolId,
type: 'symbol', type: 'symbol',
source: srcId, source: srcId,
filter: priorityFilter as never,
layout: { layout: {
visibility, visibility,
'symbol-sort-key': [ 'symbol-sort-key': [
@ -586,10 +561,10 @@ export function useGlobeShips(
'icon-color': ['coalesce', ['get', 'shipColor'], '#64748b'] as never, 'icon-color': ['coalesce', ['get', 'shipColor'], '#64748b'] as never,
'icon-opacity': [ 'icon-opacity': [
'case', 'case',
['==', ['get', 'permitted'], 1], 1, ['==', ['get', 'selected'], 1], 1,
['==', ['get', 'selected'], 1], 0.86, ['==', ['get', 'highlighted'], 1], 0.95,
['==', ['get', 'highlighted'], 1], 0.82, ['==', ['get', 'permitted'], 1], 0.93,
0.66, 0.9,
] as never, ] as never,
}, },
} as unknown as LayerSpecification, } as unknown as LayerSpecification,
@ -598,31 +573,9 @@ export function useGlobeShips(
} catch (e) { } catch (e) {
console.warn('Ship symbol layer add failed:', e); console.warn('Ship symbol layer add failed:', e);
} }
} else {
try {
map.setLayoutProperty(symbolId, 'visibility', visibility);
map.setLayoutProperty(symbolId, 'symbol-sort-key', [
'case',
['all', ['==', ['get', 'selected'], 1], ['==', ['get', 'permitted'], 1]], 140,
['all', ['==', ['get', 'highlighted'], 1], ['==', ['get', 'permitted'], 1]], 135,
['==', ['get', 'permitted'], 1], 130,
['==', ['get', 'selected'], 1], 80,
['==', ['get', 'highlighted'], 1], 75,
45,
] as never);
map.setPaintProperty(symbolId, 'icon-opacity', [
'case',
['==', ['get', 'permitted'], 1], 1,
['==', ['get', 'selected'], 1], 0.86,
['==', ['get', 'highlighted'], 1], 0.82,
0.66,
] as never);
} catch {
// ignore
}
} }
// symbol: data-driven expressions are static — visibility handled by fast toggle
const labelVisibility = overlays.shipLabels ? 'visible' : 'none';
const labelFilter = [ const labelFilter = [
'all', 'all',
['!=', ['to-string', ['coalesce', ['get', 'labelName'], '']], ''], ['!=', ['to-string', ['coalesce', ['get', 'labelName'], '']], ''],
@ -672,17 +625,14 @@ export function useGlobeShips(
} catch (e) { } catch (e) {
console.warn('Ship label layer add failed:', e); console.warn('Ship label layer add failed:', e);
} }
} else {
try {
map.setLayoutProperty(labelId, 'visibility', labelVisibility);
map.setFilter(labelId, labelFilter as never);
map.setLayoutProperty(labelId, 'text-field', ['get', 'labelName'] as never);
} catch {
// ignore
}
} }
// label: filter/text-field are static — visibility handled by fast toggle
// 레이어가 준비되었음을 알림 (projection과 무관 — 토글 버튼 활성화용)
onGlobeShipsReady?.(true);
if (projection === 'globe') {
reorderGlobeFeatureLayers(); reorderGlobeFeatureLayers();
}
kickRepaint(map); kickRepaint(map);
}; };
@ -694,12 +644,12 @@ export function useGlobeShips(
projection, projection,
settings.showShips, settings.showShips,
overlays.shipLabels, overlays.shipLabels,
shipData, globeShipGeoJson,
legacyHits,
selectedMmsi, selectedMmsi,
isBaseHighlightedMmsi, isBaseHighlightedMmsi,
mapSyncEpoch, mapSyncEpoch,
reorderGlobeFeatureLayers, reorderGlobeFeatureLayers,
onGlobeShipsReady,
]); ]);
// Globe hover overlay ships // Globe hover overlay ships
@ -713,22 +663,10 @@ export function useGlobeShips(
const outlineId = 'ships-globe-hover-outline'; const outlineId = 'ships-globe-hover-outline';
const symbolId = 'ships-globe-hover'; const symbolId = 'ships-globe-hover';
const remove = () => { const hideHover = () => {
for (const id of [symbolId, outlineId, haloId]) { for (const id of [symbolId, outlineId, haloId]) {
try { guardedSetVisibility(map, id, 'none');
if (map.getLayer(id)) map.removeLayer(id);
} catch {
// ignore
} }
}
try {
if (map.getSource(srcId)) map.removeSource(srcId);
} catch {
// ignore
}
globeHoverShipSignatureRef.current = '';
reorderGlobeFeatureLayers();
kickRepaint(map);
}; };
const ensure = () => { const ensure = () => {
@ -736,7 +674,7 @@ export function useGlobeShips(
if (!map.isStyleLoaded()) return; if (!map.isStyleLoaded()) return;
if (projection !== 'globe' || !settings.showShips || shipHoverOverlaySet.size === 0) { if (projection !== 'globe' || !settings.showShips || shipHoverOverlaySet.size === 0) {
remove(); hideHover();
return; return;
} }
@ -751,7 +689,7 @@ export function useGlobeShips(
const hovered = shipLayerData.filter((t) => shipHoverOverlaySet.has(t.mmsi) && !!legacyHits?.has(t.mmsi)); const hovered = shipLayerData.filter((t) => shipHoverOverlaySet.has(t.mmsi) && !!legacyHits?.has(t.mmsi));
if (hovered.length === 0) { if (hovered.length === 0) {
remove(); hideHover();
return; return;
} }
const hoverSignature = hovered const hoverSignature = hovered
@ -968,13 +906,14 @@ export function useGlobeShips(
if (projection !== 'globe' || !settings.showShips) return; if (projection !== 'globe' || !settings.showShips) return;
const symbolId = 'ships-globe'; const symbolId = 'ships-globe';
const symbolLiteId = 'ships-globe-lite';
const haloId = 'ships-globe-halo'; const haloId = 'ships-globe-halo';
const outlineId = 'ships-globe-outline'; const outlineId = 'ships-globe-outline';
const clickedRadiusDeg2 = Math.pow(0.08, 2); const clickedRadiusDeg2 = Math.pow(0.08, 2);
const onClick = (e: maplibregl.MapMouseEvent) => { const onClick = (e: maplibregl.MapMouseEvent) => {
try { try {
const layerIds = [symbolId, haloId, outlineId].filter((id) => map.getLayer(id)); const layerIds = [symbolId, symbolLiteId, haloId, outlineId].filter((id) => map.getLayer(id));
let feats: unknown[] = []; let feats: unknown[] = [];
if (layerIds.length > 0) { if (layerIds.length > 0) {
try { try {

파일 보기

@ -2,12 +2,11 @@ import { useCallback, useEffect, useRef, type MutableRefObject, type Dispatch, t
import maplibregl, { type StyleSpecification } from 'maplibre-gl'; import maplibregl, { type StyleSpecification } from 'maplibre-gl';
import { MapboxOverlay } from '@deck.gl/mapbox'; import { MapboxOverlay } from '@deck.gl/mapbox';
import { MaplibreDeckCustomLayer } from '../MaplibreDeckCustomLayer'; import { MaplibreDeckCustomLayer } from '../MaplibreDeckCustomLayer';
import type { BaseMapId, MapProjectionId } from '../types'; import type { BaseMapId, MapProjectionId, MapViewState } from '../types';
import { DECK_VIEW_ID } from '../constants'; import { DECK_VIEW_ID, ANCHORED_SHIP_ICON_ID } from '../constants';
import { kickRepaint, onMapStyleReady } from '../lib/mapCore'; import { kickRepaint, onMapStyleReady } from '../lib/mapCore';
import { ensureSeamarkOverlay } from '../layers/seamark'; import { ensureSeamarkOverlay } from '../layers/seamark';
import { resolveMapStyle } from '../layers/bathymetry'; import { resolveMapStyle } from '../layers/bathymetry';
import { clearGlobeNativeLayers } from '../lib/layerHelpers';
export function useMapInit( export function useMapInit(
containerRef: MutableRefObject<HTMLDivElement | null>, containerRef: MutableRefObject<HTMLDivElement | null>,
@ -23,10 +22,14 @@ export function useMapInit(
showSeamark: boolean; showSeamark: boolean;
onViewBboxChange?: (bbox: [number, number, number, number]) => void; onViewBboxChange?: (bbox: [number, number, number, number]) => void;
setMapSyncEpoch: Dispatch<SetStateAction<number>>; setMapSyncEpoch: Dispatch<SetStateAction<number>>;
initialView?: MapViewState | null;
onViewStateChange?: (view: MapViewState) => void;
}, },
) { ) {
const { onViewBboxChange, setMapSyncEpoch, showSeamark } = opts; const { onViewBboxChange, setMapSyncEpoch, showSeamark } = opts;
const showSeamarkRef = useRef(showSeamark); const showSeamarkRef = useRef(showSeamark);
const onViewStateChangeRef = useRef(opts.onViewStateChange);
useEffect(() => { onViewStateChangeRef.current = opts.onViewStateChange; }, [opts.onViewStateChange]);
useEffect(() => { useEffect(() => {
showSeamarkRef.current = showSeamark; showSeamarkRef.current = showSeamark;
}, [showSeamark]); }, [showSeamark]);
@ -46,12 +49,6 @@ export function useMapInit(
} }
}, []); }, []);
const clearGlobeNativeLayersCb = useCallback(() => {
const map = mapRef.current;
if (!map) return;
clearGlobeNativeLayers(map);
}, []);
const pulseMapSync = useCallback(() => { const pulseMapSync = useCallback(() => {
setMapSyncEpoch((prev) => prev + 1); setMapSyncEpoch((prev) => prev + 1);
requestAnimationFrame(() => { requestAnimationFrame(() => {
@ -65,6 +62,7 @@ export function useMapInit(
let map: maplibregl.Map | null = null; let map: maplibregl.Map | null = null;
let cancelled = false; let cancelled = false;
let viewSaveTimer: ReturnType<typeof setInterval> | null = null;
const controller = new AbortController(); const controller = new AbortController();
(async () => { (async () => {
@ -77,13 +75,14 @@ export function useMapInit(
} }
if (cancelled || !containerRef.current) return; if (cancelled || !containerRef.current) return;
const iv = opts.initialView;
map = new maplibregl.Map({ map = new maplibregl.Map({
container: containerRef.current, container: containerRef.current,
style, style,
center: [126.5, 34.2], center: iv?.center ?? [126.5, 34.2],
zoom: 7, zoom: iv?.zoom ?? 7,
pitch: 45, pitch: iv?.pitch ?? 45,
bearing: 0, bearing: iv?.bearing ?? 0,
maxPitch: 85, maxPitch: 85,
dragRotate: true, dragRotate: true,
pitchWithRotate: true, pitchWithRotate: true,
@ -94,19 +93,72 @@ export function useMapInit(
map.addControl(new maplibregl.NavigationControl({ showZoom: true, showCompass: true }), 'top-left'); map.addControl(new maplibregl.NavigationControl({ showZoom: true, showCompass: true }), 'top-left');
map.addControl(new maplibregl.ScaleControl({ maxWidth: 120, unit: 'metric' }), 'bottom-left'); map.addControl(new maplibregl.ScaleControl({ maxWidth: 120, unit: 'metric' }), 'bottom-left');
// MapLibre 내부 placement TypeError 방어 + globe easing 경고 억제
// symbol layer 추가/제거와 render cycle 간 타이밍 이슈로 발생하는 에러 억제
// globe projection에서 scrollZoom이 easeTo(around)를 호출하면 경고 발생 → 구조적 한계로 억제
{
const origRender = (map as unknown as { _render: (arg?: number) => void })._render;
const origWarn = console.warn;
(map as unknown as { _render: (arg?: number) => void })._render = function (arg?: number) {
// globe 모드에서 scrollZoom의 easeTo around 경고 억제
// eslint-disable-next-line no-console
console.warn = function (...args: unknown[]) {
if (typeof args[0] === 'string') {
const msg = args[0] as string;
if (msg.includes('Easing around a point')) return;
// vertex 경고는 디버그용으로 1회만 출력 후 억제
if (msg.includes('Max vertices per segment')) {
origWarn.apply(console, args as [unknown, ...unknown[]]);
return;
}
}
origWarn.apply(console, args as [unknown, ...unknown[]]);
};
try {
origRender.call(this, arg);
} catch (e) {
if (e instanceof TypeError && (e.message?.includes("reading 'get'") || e.message?.includes('placement'))) {
return;
}
throw e;
} finally {
// eslint-disable-next-line no-console
console.warn = origWarn;
}
};
}
// Globe 모드 전환 시 지연을 제거하기 위해 ship.svg를 미리 로드
{
const SHIP_IMG_ID = 'ship-globe-icon';
const localMap = map;
void localMap
.loadImage('/assets/ship.svg')
.then((response) => {
if (cancelled || !localMap) return;
const img = (response as { data?: HTMLImageElement | ImageBitmap } | undefined)?.data;
if (!img) return;
try {
if (!localMap.hasImage(SHIP_IMG_ID)) localMap.addImage(SHIP_IMG_ID, img, { pixelRatio: 2, sdf: true });
if (!localMap.hasImage(ANCHORED_SHIP_ICON_ID)) localMap.addImage(ANCHORED_SHIP_ICON_ID, img, { pixelRatio: 2, sdf: true });
} catch {
// ignore — fallback canvas icon이 useGlobeShips에서 사용됨
}
})
.catch(() => {
// ignore — useGlobeShips에서 fallback 처리
});
}
mapRef.current = map; mapRef.current = map;
if (projectionRef.current === 'mercator') { // 양쪽 overlay를 모두 초기화 — projection 전환 시 재생성 비용 제거
const overlay = ensureMercatorOverlay(); ensureMercatorOverlay();
if (!overlay) return;
overlayRef.current = overlay;
} else {
globeDeckLayerRef.current = new MaplibreDeckCustomLayer({ globeDeckLayerRef.current = new MaplibreDeckCustomLayer({
id: 'deck-globe', id: 'deck-globe',
viewId: DECK_VIEW_ID, viewId: DECK_VIEW_ID,
deckProps: { layers: [] }, deckProps: { layers: [] },
}); });
}
function applyProjection() { function applyProjection() {
if (!map) return; if (!map) return;
@ -122,8 +174,9 @@ export function useMapInit(
onMapStyleReady(map, () => { onMapStyleReady(map, () => {
applyProjection(); applyProjection();
// deck-globe를 항상 추가 (projection과 무관)
const deckLayer = globeDeckLayerRef.current; const deckLayer = globeDeckLayerRef.current;
if (projectionRef.current === 'globe' && deckLayer && !map!.getLayer(deckLayer.id)) { if (deckLayer && !map!.getLayer(deckLayer.id)) {
try { try {
map!.addLayer(deckLayer); map!.addLayer(deckLayer);
} catch { } catch {
@ -132,7 +185,7 @@ export function useMapInit(
} }
if (!showSeamarkRef.current) return; if (!showSeamarkRef.current) return;
try { try {
ensureSeamarkOverlay(map!, 'bathymetry-lines'); ensureSeamarkOverlay(map!, 'bathymetry-lines-coarse');
} catch { } catch {
// ignore // ignore
} }
@ -147,10 +200,38 @@ export function useMapInit(
map.on('load', emitBbox); map.on('load', emitBbox);
map.on('moveend', emitBbox); map.on('moveend', emitBbox);
// 60초 인터벌로 뷰 상태 저장 (mercator일 때만)
viewSaveTimer = setInterval(() => {
const cb = onViewStateChangeRef.current;
if (!cb || !map || projectionRef.current !== 'mercator') return;
const c = map.getCenter();
cb({ center: [c.lng, c.lat], zoom: map.getZoom(), bearing: map.getBearing(), pitch: map.getPitch() });
}, 60_000);
map.once('load', () => { map.once('load', () => {
// Globe 배경(타일 밖)을 심해 색상과 맞춰 타일 경계 seam을 비가시화
try {
map!.setSky({
'sky-color': '#010610',
'horizon-color': '#010610',
'fog-color': '#010610',
'fog-ground-blend': 1,
'sky-horizon-blend': 0,
'atmosphere-blend': 0,
});
} catch {
// ignore
}
// 캔버스 배경도 심해색으로 통일
try {
map!.getCanvas().style.background = '#010610';
} catch {
// ignore
}
if (showSeamarkRef.current) { if (showSeamarkRef.current) {
try { try {
ensureSeamarkOverlay(map!, 'bathymetry-lines'); ensureSeamarkOverlay(map!, 'bathymetry-lines-coarse');
} catch { } catch {
// ignore // ignore
} }
@ -161,12 +242,22 @@ export function useMapInit(
// ignore // ignore
} }
} }
// 종속 hook들(useMapStyleSettings 등)이 저장된 설정을 적용하도록 트리거
setMapSyncEpoch((prev) => prev + 1);
}); });
})(); })();
return () => { return () => {
cancelled = true; cancelled = true;
controller.abort(); controller.abort();
if (viewSaveTimer) clearInterval(viewSaveTimer);
// 최종 뷰 상태 저장 (mercator일 때만 — globe 위치는 영속화하지 않음)
const cb = onViewStateChangeRef.current;
if (cb && map && projectionRef.current === 'mercator') {
const c = map.getCenter();
cb({ center: [c.lng, c.lat], zoom: map.getZoom(), bearing: map.getBearing(), pitch: map.getPitch() });
}
try { try {
globeDeckLayerRef.current?.requestFinalize(); globeDeckLayerRef.current?.requestFinalize();
@ -192,5 +283,5 @@ export function useMapInit(
// eslint-disable-next-line react-hooks/exhaustive-deps // eslint-disable-next-line react-hooks/exhaustive-deps
}, []); }, []);
return { ensureMercatorOverlay, clearGlobeNativeLayers: clearGlobeNativeLayersCb, pulseMapSync }; return { ensureMercatorOverlay, pulseMapSync };
} }

파일 보기

@ -35,7 +35,7 @@ function applyLabelLanguage(map: maplibregl.Map, lang: MapLabelLanguage) {
if (layer.type !== 'symbol') continue; if (layer.type !== 'symbol') continue;
const layout = (layer as { layout?: Record<string, unknown> }).layout; const layout = (layer as { layout?: Record<string, unknown> }).layout;
if (!layout?.['text-field']) continue; if (!layout?.['text-field']) continue;
if (layer.id === 'bathymetry-labels') continue; if (layer.id.startsWith('bathymetry-labels')) continue;
const textField = const textField =
lang === 'local' lang === 'local'
? ['get', 'name'] ? ['get', 'name']
@ -63,6 +63,7 @@ function applyLandColor(map: maplibregl.Map, color: string) {
if (id.startsWith('fc-')) continue; if (id.startsWith('fc-')) continue;
if (id.startsWith('fleet-')) continue; if (id.startsWith('fleet-')) continue;
if (id.startsWith('predict-')) continue; if (id.startsWith('predict-')) continue;
if (id.startsWith('vessel-track-')) continue;
if (id === 'deck-globe') continue; if (id === 'deck-globe') continue;
const sourceLayer = String((layer as Record<string, unknown>)['source-layer'] ?? ''); const sourceLayer = String((layer as Record<string, unknown>)['source-layer'] ?? '');
const isWater = waterRegex.test(id) || waterRegex.test(sourceLayer); const isWater = waterRegex.test(id) || waterRegex.test(sourceLayer);
@ -102,17 +103,19 @@ function applyWaterBaseColor(map: maplibregl.Map, fillColor: string) {
} }
function applyDepthGradient(map: maplibregl.Map, stops: DepthColorStop[]) { function applyDepthGradient(map: maplibregl.Map, stops: DepthColorStop[]) {
if (!map.getLayer('bathymetry-fill')) return;
const depth = ['to-number', ['get', 'depth']]; const depth = ['to-number', ['get', 'depth']];
const sorted = [...stops].sort((a, b) => a.depth - b.depth); const sorted = [...stops].sort((a, b) => a.depth - b.depth);
if (sorted.length === 0) return;
const expr: unknown[] = ['interpolate', ['linear'], depth]; const expr: unknown[] = ['interpolate', ['linear'], depth];
const deepest = sorted[0];
if (deepest) expr.push(-11000, darkenHex(deepest.color, 0.5));
for (const s of sorted) { for (const s of sorted) {
expr.push(s.depth, s.color); expr.push(s.depth, s.color);
} }
// 0m까지 확장 (최천층 stop이 0보다 깊으면)
const shallowest = sorted[sorted.length - 1]; const shallowest = sorted[sorted.length - 1];
if (shallowest) expr.push(0, lightenHex(shallowest.color, 1.8)); if (shallowest.depth < 0) {
expr.push(0, lightenHex(shallowest.color, 1.8));
}
if (!map.getLayer('bathymetry-fill')) return;
try { try {
map.setPaintProperty('bathymetry-fill', 'fill-color', expr as never); map.setPaintProperty('bathymetry-fill', 'fill-color', expr as never);
} catch { } catch {
@ -122,7 +125,7 @@ function applyDepthGradient(map: maplibregl.Map, stops: DepthColorStop[]) {
function applyDepthFontSize(map: maplibregl.Map, size: DepthFontSize) { function applyDepthFontSize(map: maplibregl.Map, size: DepthFontSize) {
const expr = DEPTH_FONT_SIZE_MAP[size]; const expr = DEPTH_FONT_SIZE_MAP[size];
for (const layerId of ['bathymetry-labels', 'bathymetry-landforms']) { for (const layerId of ['bathymetry-labels', 'bathymetry-labels-coarse', 'bathymetry-landforms']) {
if (!map.getLayer(layerId)) continue; if (!map.getLayer(layerId)) continue;
try { try {
map.setLayoutProperty(layerId, 'text-size', expr); map.setLayoutProperty(layerId, 'text-size', expr);
@ -133,7 +136,7 @@ function applyDepthFontSize(map: maplibregl.Map, size: DepthFontSize) {
} }
function applyDepthFontColor(map: maplibregl.Map, color: string) { function applyDepthFontColor(map: maplibregl.Map, color: string) {
for (const layerId of ['bathymetry-labels', 'bathymetry-landforms']) { for (const layerId of ['bathymetry-labels', 'bathymetry-labels-coarse', 'bathymetry-landforms']) {
if (!map.getLayer(layerId)) continue; if (!map.getLayer(layerId)) continue;
try { try {
map.setPaintProperty(layerId, 'text-color', color); map.setPaintProperty(layerId, 'text-color', color);

파일 보기

@ -84,16 +84,27 @@ export function useNativeMapLayers(
useEffect(() => { useEffect(() => {
const map = mapRef.current; const map = mapRef.current;
if (!map) return; if (!map) return;
let cancelled = false;
let retryRaf: number | null = null;
const ensure = () => { const ensure = () => {
if (cancelled) return;
const cfg = configRef.current; const cfg = configRef.current;
if (projectionBusyRef.current) return;
// 1. Visibility 토글 // 1. Visibility 토글
for (const spec of cfg.layers) { for (const spec of cfg.layers) {
setLayerVisibility(map, spec.id, cfg.visible); setLayerVisibility(map, spec.id, cfg.visible);
} }
// projection transition 중에는 가시성 토글만 먼저 반영하고,
// source/layer 업데이트는 transition 종료 후 재시도한다.
if (projectionBusyRef.current) {
if (cfg.visible) {
retryRaf = requestAnimationFrame(ensure);
}
return;
}
// 2. 데이터가 있는 source가 하나도 없으면 종료 // 2. 데이터가 있는 source가 하나도 없으면 종료
const hasData = cfg.sources.some((s) => s.data != null); const hasData = cfg.sources.some((s) => s.data != null);
if (!hasData) return; if (!hasData) return;
@ -150,6 +161,10 @@ export function useNativeMapLayers(
const stop = onMapStyleReady(map, ensure); const stop = onMapStyleReady(map, ensure);
return () => { return () => {
cancelled = true;
if (retryRaf != null) {
cancelAnimationFrame(retryRaf);
}
stop(); stop();
}; };
// eslint-disable-next-line react-hooks/exhaustive-deps // eslint-disable-next-line react-hooks/exhaustive-deps

파일 보기

@ -3,9 +3,7 @@ import type maplibregl from 'maplibre-gl';
import { MapboxOverlay } from '@deck.gl/mapbox'; import { MapboxOverlay } from '@deck.gl/mapbox';
import { MaplibreDeckCustomLayer } from '../MaplibreDeckCustomLayer'; import { MaplibreDeckCustomLayer } from '../MaplibreDeckCustomLayer';
import type { MapProjectionId } from '../types'; import type { MapProjectionId } from '../types';
import { DECK_VIEW_ID } from '../constants';
import { kickRepaint, onMapStyleReady, extractProjectionType } from '../lib/mapCore'; import { kickRepaint, onMapStyleReady, extractProjectionType } from '../lib/mapCore';
import { removeLayerIfExists } from '../lib/layerHelpers';
export function useProjectionToggle( export function useProjectionToggle(
mapRef: MutableRefObject<maplibregl.Map | null>, mapRef: MutableRefObject<maplibregl.Map | null>,
@ -15,14 +13,13 @@ export function useProjectionToggle(
projectionBusyRef: MutableRefObject<boolean>, projectionBusyRef: MutableRefObject<boolean>,
opts: { opts: {
projection: MapProjectionId; projection: MapProjectionId;
clearGlobeNativeLayers: () => void;
ensureMercatorOverlay: () => MapboxOverlay | null; ensureMercatorOverlay: () => MapboxOverlay | null;
onProjectionLoadingChange?: (loading: boolean) => void; onProjectionLoadingChange?: (loading: boolean) => void;
pulseMapSync: () => void; pulseMapSync: () => void;
setMapSyncEpoch: (updater: (prev: number) => number) => void; setMapSyncEpoch: (updater: (prev: number) => number) => void;
}, },
): () => void { ): () => void {
const { projection, clearGlobeNativeLayers, ensureMercatorOverlay, onProjectionLoadingChange, pulseMapSync, setMapSyncEpoch } = opts; const { projection, ensureMercatorOverlay, onProjectionLoadingChange, pulseMapSync, setMapSyncEpoch } = opts;
const projectionBusyTokenRef = useRef(0); const projectionBusyTokenRef = useRef(0);
const projectionBusyTimerRef = useRef<ReturnType<typeof window.setTimeout> | null>(null); const projectionBusyTimerRef = useRef<ReturnType<typeof window.setTimeout> | null>(null);
@ -71,7 +68,7 @@ export function useProjectionToggle(
if (!projectionBusyRef.current || projectionBusyTokenRef.current !== token) return; if (!projectionBusyRef.current || projectionBusyTokenRef.current !== token) return;
console.debug('Projection loading fallback timeout reached.'); console.debug('Projection loading fallback timeout reached.');
endProjectionLoading(); endProjectionLoading();
}, 4000); }, 2000);
}, },
[clearProjectionBusyTimer, endProjectionLoading, onProjectionLoadingChange], [clearProjectionBusyTimer, endProjectionLoading, onProjectionLoadingChange],
); );
@ -97,6 +94,15 @@ export function useProjectionToggle(
'subcables-glow', 'subcables-glow',
'subcables-points', 'subcables-points',
'subcables-label', 'subcables-label',
'vessel-track-line',
'vessel-track-line-hitarea',
'vessel-track-arrow',
'vessel-track-pts',
'vessel-track-pts-highlight',
'track-replay-globe-path',
'track-replay-globe-points',
'track-replay-globe-virtual-ship',
'track-replay-globe-virtual-label',
'zones-fill', 'zones-fill',
'zones-line', 'zones-line',
'zones-label', 'zones-label',
@ -106,6 +112,7 @@ export function useProjectionToggle(
'predict-vectors-hl', 'predict-vectors-hl',
'ships-globe-halo', 'ships-globe-halo',
'ships-globe-outline', 'ships-globe-outline',
'ships-globe-lite',
'ships-globe', 'ships-globe',
'ships-globe-label', 'ships-globe-label',
'ships-globe-hover-halo', 'ships-globe-hover-halo',
@ -176,45 +183,14 @@ export function useProjectionToggle(
if (isTransition) setProjectionLoading(true); if (isTransition) setProjectionLoading(true);
const disposeMercatorOverlays = () => { // 파괴하지 않고 레이어만 비움 — 양쪽 파이프라인 항상 유지
const disposeOne = (target: MapboxOverlay | null, toNull: 'base' | 'interaction') => { const quietMercatorOverlays = () => {
if (!target) return; try { overlayRef.current?.setProps({ layers: [] } as never); } catch { /* ignore */ }
try { try { overlayInteractionRef.current?.setProps({ layers: [] } as never); } catch { /* ignore */ }
target.setProps({ layers: [] } as never);
} catch {
// ignore
}
try {
map.removeControl(target as never);
} catch {
// ignore
}
try {
target.finalize();
} catch {
// ignore
}
if (toNull === 'base') {
overlayRef.current = null;
} else {
overlayInteractionRef.current = null;
}
}; };
disposeOne(overlayRef.current, 'base'); const quietGlobeDeckLayer = () => {
disposeOne(overlayInteractionRef.current, 'interaction'); try { globeDeckLayerRef.current?.setProps({ layers: [] } as never); } catch { /* ignore */ }
};
const disposeGlobeDeckLayer = () => {
const current = globeDeckLayerRef.current;
if (!current) return;
removeLayerIfExists(map, current.id);
try {
current.requestFinalize();
} catch {
// ignore
}
globeDeckLayerRef.current = null;
}; };
const syncProjectionAndDeck = () => { const syncProjectionAndDeck = () => {
@ -236,11 +212,9 @@ export function useProjectionToggle(
const shouldSwitchProjection = currentProjection !== next; const shouldSwitchProjection = currentProjection !== next;
if (projection === 'globe') { if (projection === 'globe') {
disposeMercatorOverlays(); quietMercatorOverlays();
clearGlobeNativeLayers();
} else { } else {
disposeGlobeDeckLayer(); quietGlobeDeckLayer();
clearGlobeNativeLayers();
} }
try { try {
@ -248,6 +222,17 @@ export function useProjectionToggle(
map.setProjection({ type: next }); map.setProjection({ type: next });
} }
map.setRenderWorldCopies(next !== 'globe'); map.setRenderWorldCopies(next !== 'globe');
// Globe에서는 easeTo around 미지원 → scrollZoom 동작 전환
try {
map.scrollZoom.disable();
if (next === 'globe') {
map.scrollZoom.enable();
} else {
map.scrollZoom.enable({ around: 'center' });
}
} catch { /* ignore */ }
if (shouldSwitchProjection && currentProjection !== next && !cancelled && retries < maxRetries) { if (shouldSwitchProjection && currentProjection !== next && !cancelled && retries < maxRetries) {
retries += 1; retries += 1;
window.requestAnimationFrame(() => syncProjectionAndDeck()); window.requestAnimationFrame(() => syncProjectionAndDeck());
@ -263,17 +248,9 @@ export function useProjectionToggle(
console.warn('Projection switch failed:', e); console.warn('Projection switch failed:', e);
} }
// 양쪽 overlay가 항상 존재하므로 재생성 불필요
// deck-globe가 map에서 빠져있을 경우에만 재추가
if (projection === 'globe') { if (projection === 'globe') {
disposeGlobeDeckLayer();
if (!globeDeckLayerRef.current) {
globeDeckLayerRef.current = new MaplibreDeckCustomLayer({
id: 'deck-globe',
viewId: DECK_VIEW_ID,
deckProps: { layers: [] },
});
}
const layer = globeDeckLayerRef.current; const layer = globeDeckLayerRef.current;
const layerId = layer?.id; const layerId = layer?.id;
if (layer && layerId && map.isStyleLoaded() && !map.getLayer(layerId)) { if (layer && layerId && map.isStyleLoaded() && !map.getLayer(layerId)) {
@ -282,14 +259,8 @@ export function useProjectionToggle(
} catch { } catch {
// ignore // ignore
} }
if (!map.getLayer(layerId) && !cancelled && retries < maxRetries) {
retries += 1;
window.requestAnimationFrame(() => syncProjectionAndDeck());
return;
}
} }
} else { } else {
disposeGlobeDeckLayer();
ensureMercatorOverlay(); ensureMercatorOverlay();
} }
@ -324,7 +295,7 @@ export function useProjectionToggle(
if (settleCleanup) settleCleanup(); if (settleCleanup) settleCleanup();
if (isTransition) setProjectionLoading(false); if (isTransition) setProjectionLoading(false);
}; };
}, [projection, clearGlobeNativeLayers, ensureMercatorOverlay, reorderGlobeFeatureLayers, setProjectionLoading]); }, [projection, ensureMercatorOverlay, reorderGlobeFeatureLayers, setProjectionLoading]);
return reorderGlobeFeatureLayers; return reorderGlobeFeatureLayers;
} }

파일 보기

@ -16,6 +16,7 @@ const LINE_ID = 'subcables-line';
const GLOW_ID = 'subcables-glow'; const GLOW_ID = 'subcables-glow';
const POINTS_ID = 'subcables-points'; const POINTS_ID = 'subcables-points';
const LABEL_ID = 'subcables-label'; const LABEL_ID = 'subcables-label';
const HOVER_LABEL_ID = 'subcables-hover-label';
/* ── Paint defaults (used for layer creation + hover reset) ──────── */ /* ── Paint defaults (used for layer creation + hover reset) ──────── */
const LINE_OPACITY_DEFAULT = ['interpolate', ['linear'], ['zoom'], 2, 0.7, 6, 0.82, 10, 0.92]; const LINE_OPACITY_DEFAULT = ['interpolate', ['linear'], ['zoom'], 2, 0.7, 6, 0.82, 10, 0.92];
@ -63,10 +64,10 @@ const LAYER_SPECS: NativeLayerSpec[] = [
type: 'line', type: 'line',
sourceId: SRC_ID, sourceId: SRC_ID,
paint: { paint: {
'line-color': ['get', 'color'], 'line-color': '#ffffff',
'line-opacity': 0, 'line-opacity': 0,
'line-width': ['interpolate', ['linear'], ['zoom'], 2, 5, 6, 8, 10, 12], 'line-width': ['interpolate', ['linear'], ['zoom'], 2, 10, 6, 16, 10, 24],
'line-blur': ['interpolate', ['linear'], ['zoom'], 2, 3, 6, 5, 10, 7], 'line-blur': ['interpolate', ['linear'], ['zoom'], 2, 4, 6, 6, 10, 8],
}, },
filter: ['==', ['get', 'id'], ''], filter: ['==', ['get', 'id'], ''],
layout: { 'line-cap': 'round', 'line-join': 'round' }, layout: { 'line-cap': 'round', 'line-join': 'round' },
@ -107,6 +108,29 @@ const LAYER_SPECS: NativeLayerSpec[] = [
}, },
minzoom: 4, minzoom: 4,
}, },
{
id: HOVER_LABEL_ID,
type: 'symbol',
sourceId: SRC_ID,
paint: {
'text-color': '#ffffff',
'text-halo-color': 'rgba(0,0,0,0.85)',
'text-halo-width': 2,
'text-halo-blur': 0.5,
'text-opacity': 0,
},
layout: {
'symbol-placement': 'line',
'text-field': ['get', 'name'],
'text-size': ['interpolate', ['linear'], ['zoom'], 2, 14, 6, 17, 10, 20],
'text-font': ['Noto Sans Bold', 'Open Sans Bold'],
'text-allow-overlap': true,
'text-padding': 2,
'text-rotation-alignment': 'map',
},
filter: ['==', ['get', 'id'], ''],
minzoom: 2,
},
]; ];
export function useSubcablesLayer( export function useSubcablesLayer(
@ -250,42 +274,27 @@ export function useSubcablesLayer(
} }
/* ── Hover highlight helper (paint-only mutations) ────────────────── */ /* ── Hover highlight helper (paint-only mutations) ────────────────── */
// 기본 레이어는 항상 기본값 유지, glow 레이어(filter 기반)로만 호버 강조
function applyHoverHighlight(map: maplibregl.Map, hoveredId: string | null) { function applyHoverHighlight(map: maplibregl.Map, hoveredId: string | null) {
const noMatch = ['==', ['get', 'id'], ''] as never;
if (hoveredId) { if (hoveredId) {
const matchExpr = ['==', ['get', 'id'], hoveredId]; const matchExpr = ['==', ['get', 'id'], hoveredId];
if (map.getLayer(LINE_ID)) {
map.setPaintProperty(LINE_ID, 'line-opacity', ['case', matchExpr, 1.0, 0.2] as never);
map.setPaintProperty(LINE_ID, 'line-width', ['case', matchExpr, 4.5, 1.2] as never);
}
if (map.getLayer(CASING_ID)) {
map.setPaintProperty(CASING_ID, 'line-opacity', ['case', matchExpr, 0.7, 0.12] as never);
map.setPaintProperty(CASING_ID, 'line-width', ['case', matchExpr, 6.5, 2.0] as never);
}
if (map.getLayer(GLOW_ID)) { if (map.getLayer(GLOW_ID)) {
map.setFilter(GLOW_ID, matchExpr as never); map.setFilter(GLOW_ID, matchExpr as never);
map.setPaintProperty(GLOW_ID, 'line-opacity', 0.35); map.setPaintProperty(GLOW_ID, 'line-opacity', 0.55);
} }
if (map.getLayer(POINTS_ID)) { if (map.getLayer(HOVER_LABEL_ID)) {
map.setPaintProperty(POINTS_ID, 'circle-opacity', ['case', matchExpr, 1.0, 0.15] as never); map.setFilter(HOVER_LABEL_ID, matchExpr as never);
map.setPaintProperty(POINTS_ID, 'circle-radius', ['case', matchExpr, 4, 1.5] as never); map.setPaintProperty(HOVER_LABEL_ID, 'text-opacity', 1.0);
} }
} else { } else {
if (map.getLayer(LINE_ID)) {
map.setPaintProperty(LINE_ID, 'line-opacity', LINE_OPACITY_DEFAULT as never);
map.setPaintProperty(LINE_ID, 'line-width', LINE_WIDTH_DEFAULT as never);
}
if (map.getLayer(CASING_ID)) {
map.setPaintProperty(CASING_ID, 'line-opacity', CASING_OPACITY_DEFAULT as never);
map.setPaintProperty(CASING_ID, 'line-width', CASING_WIDTH_DEFAULT as never);
}
if (map.getLayer(GLOW_ID)) { if (map.getLayer(GLOW_ID)) {
map.setFilter(GLOW_ID, ['==', ['get', 'id'], ''] as never); map.setFilter(GLOW_ID, noMatch);
map.setPaintProperty(GLOW_ID, 'line-opacity', 0); map.setPaintProperty(GLOW_ID, 'line-opacity', 0);
} }
if (map.getLayer(POINTS_ID)) { if (map.getLayer(HOVER_LABEL_ID)) {
map.setPaintProperty(POINTS_ID, 'circle-opacity', POINTS_OPACITY_DEFAULT as never); map.setFilter(HOVER_LABEL_ID, noMatch);
map.setPaintProperty(POINTS_ID, 'circle-radius', POINTS_RADIUS_DEFAULT as never); map.setPaintProperty(HOVER_LABEL_ID, 'text-opacity', 0);
} }
} }
} }

파일 보기

@ -0,0 +1,242 @@
import { useEffect, useMemo, type MutableRefObject } from 'react';
import type maplibregl from 'maplibre-gl';
import type { ActiveTrack } from '../../../entities/vesselTrack/model/types';
import { convertLegacyTrackPointsToProcessedTrack } from '../../../features/trackReplay/lib/adapters';
import { useTrackQueryStore } from '../../../features/trackReplay/stores/trackQueryStore';
import type { TrackReplayDeckRenderState } from '../../../features/trackReplay/hooks/useTrackReplayDeckLayers';
import { useNativeMapLayers, type NativeLayerSpec, type NativeSourceConfig } from './useNativeMapLayers';
import type { MapProjectionId } from '../types';
import { kickRepaint } from '../lib/mapCore';
const GLOBE_LINE_SRC = 'track-replay-globe-line-src';
const GLOBE_POINT_SRC = 'track-replay-globe-point-src';
const GLOBE_VIRTUAL_SRC = 'track-replay-globe-virtual-src';
const GLOBE_TRACK_LAYER_IDS = {
PATH: 'track-replay-globe-path',
POINTS: 'track-replay-globe-points',
VIRTUAL_SHIP: 'track-replay-globe-virtual-ship',
VIRTUAL_LABEL: 'track-replay-globe-virtual-label',
} as const;
function toFiniteNumber(value: unknown): number | null {
if (typeof value === 'number') return Number.isFinite(value) ? value : null;
if (typeof value === 'string') {
const parsed = Number(value.trim());
return Number.isFinite(parsed) ? parsed : null;
}
return null;
}
function normalizeCoordinate(value: unknown): [number, number] | null {
if (!Array.isArray(value) || value.length !== 2) return null;
const lon = toFiniteNumber(value[0]);
const lat = toFiniteNumber(value[1]);
if (lon == null || lat == null) return null;
return [lon, lat];
}
export function useTrackReplayLayer(
mapRef: MutableRefObject<maplibregl.Map | null>,
projectionBusyRef: MutableRefObject<boolean>,
reorderGlobeFeatureLayers: () => void,
opts: {
projection: MapProjectionId;
mapSyncEpoch: number;
activeTrack?: ActiveTrack | null;
renderState: TrackReplayDeckRenderState;
},
) {
const { projection, mapSyncEpoch, activeTrack = null, renderState } = opts;
const { enabledTracks, currentPositions, showPoints, showVirtualShip, showLabels, renderEpoch } = renderState;
const setTracks = useTrackQueryStore((state) => state.setTracks);
// Backward compatibility path: if legacy activeTrack is provided, load it into the new store.
useEffect(() => {
if (!activeTrack) return;
if (!activeTrack.points || activeTrack.points.length === 0) return;
const converted = convertLegacyTrackPointsToProcessedTrack(activeTrack.mmsi, activeTrack.points);
if (!converted) return;
setTracks([converted]);
}, [activeTrack, setTracks]);
const lineGeoJson = useMemo<GeoJSON.FeatureCollection<GeoJSON.LineString>>(() => {
const features: GeoJSON.Feature<GeoJSON.LineString>[] = [];
for (const track of enabledTracks) {
const coordinates: [number, number][] = [];
for (const coord of track.geometry) {
const normalized = normalizeCoordinate(coord);
if (normalized) coordinates.push(normalized);
}
if (coordinates.length < 2) continue;
features.push({
type: 'Feature',
properties: {
vesselId: track.vesselId,
shipName: track.shipName,
},
geometry: {
type: 'LineString',
coordinates,
},
});
}
return {
type: 'FeatureCollection',
features,
};
}, [enabledTracks]);
const pointGeoJson = useMemo<GeoJSON.FeatureCollection<GeoJSON.Point>>(() => {
const features: GeoJSON.Feature<GeoJSON.Point>[] = [];
for (const track of enabledTracks) {
track.geometry.forEach((coord, index) => {
const normalized = normalizeCoordinate(coord);
if (!normalized) return;
features.push({
type: 'Feature',
properties: {
vesselId: track.vesselId,
shipName: track.shipName,
index,
},
geometry: {
type: 'Point',
coordinates: normalized,
},
});
});
}
return {
type: 'FeatureCollection',
features,
};
}, [enabledTracks]);
const virtualGeoJson = useMemo<GeoJSON.FeatureCollection<GeoJSON.Point>>(() => {
const features: GeoJSON.Feature<GeoJSON.Point>[] = [];
for (const position of currentPositions) {
const normalized = normalizeCoordinate(position.position);
if (!normalized) continue;
features.push({
type: 'Feature',
properties: {
vesselId: position.vesselId,
shipName: position.shipName,
},
geometry: {
type: 'Point',
coordinates: normalized,
},
});
}
return {
type: 'FeatureCollection',
features,
};
}, [currentPositions]);
const globeSources = useMemo<NativeSourceConfig[]>(
() => [
{ id: GLOBE_LINE_SRC, data: lineGeoJson },
{ id: GLOBE_POINT_SRC, data: pointGeoJson },
{ id: GLOBE_VIRTUAL_SRC, data: virtualGeoJson },
],
[lineGeoJson, pointGeoJson, virtualGeoJson],
);
const globeLayers = useMemo<NativeLayerSpec[]>(
() => [
{
id: GLOBE_TRACK_LAYER_IDS.PATH,
type: 'line',
sourceId: GLOBE_LINE_SRC,
paint: {
'line-color': '#00d1ff',
'line-width': ['interpolate', ['linear'], ['zoom'], 3, 1.5, 8, 3, 12, 4],
'line-opacity': 0.8,
},
layout: {
'line-cap': 'round',
'line-join': 'round',
},
},
{
id: GLOBE_TRACK_LAYER_IDS.POINTS,
type: 'circle',
sourceId: GLOBE_POINT_SRC,
paint: {
'circle-radius': ['interpolate', ['linear'], ['zoom'], 3, 1.5, 8, 3, 12, 4],
'circle-color': '#00d1ff',
'circle-opacity': showPoints ? 0.8 : 0,
'circle-stroke-width': 1,
'circle-stroke-color': 'rgba(2,6,23,0.8)',
},
},
{
id: GLOBE_TRACK_LAYER_IDS.VIRTUAL_SHIP,
type: 'circle',
sourceId: GLOBE_VIRTUAL_SRC,
paint: {
'circle-radius': ['interpolate', ['linear'], ['zoom'], 3, 2.5, 8, 4, 12, 6],
'circle-color': '#f59e0b',
'circle-opacity': showVirtualShip ? 0.9 : 0,
'circle-stroke-width': 1,
'circle-stroke-color': 'rgba(255,255,255,0.8)',
},
},
{
id: GLOBE_TRACK_LAYER_IDS.VIRTUAL_LABEL,
type: 'symbol',
sourceId: GLOBE_VIRTUAL_SRC,
paint: {
'text-color': 'rgba(226,232,240,0.95)',
'text-opacity': showLabels ? 1 : 0,
'text-halo-color': 'rgba(2,6,23,0.85)',
'text-halo-width': 1,
},
layout: {
'text-field': ['get', 'shipName'],
'text-size': 11,
'text-anchor': 'left',
'text-offset': [0.8, 0],
},
},
],
[showLabels, showPoints, showVirtualShip],
);
const isGlobeVisible = projection === 'globe' && enabledTracks.length > 0;
useNativeMapLayers(
mapRef,
projectionBusyRef,
reorderGlobeFeatureLayers,
{
sources: globeSources,
layers: globeLayers,
visible: isGlobeVisible,
beforeLayer: ['zones-fill', 'zones-line'],
},
[projection, mapSyncEpoch, renderEpoch, lineGeoJson, pointGeoJson, virtualGeoJson, showPoints, showVirtualShip, showLabels],
);
useEffect(() => {
if (projection !== 'globe') return;
if (!isGlobeVisible) return;
const map = mapRef.current;
if (!map || projectionBusyRef.current) return;
kickRepaint(map);
const id = requestAnimationFrame(() => {
kickRepaint(map);
});
return () => cancelAnimationFrame(id);
}, [projection, isGlobeVisible, renderEpoch, mapRef, projectionBusyRef]);
}

파일 보기

@ -0,0 +1,396 @@
/**
* useVesselTrackLayer (Track) hook
*
* Mercator: TripsLayer + ScatterplotLayer
* Globe: MapLibre line + circle + symbol(arrow)
*/
import { useCallback, useEffect, useMemo, useRef, type MutableRefObject } from 'react';
import maplibregl from 'maplibre-gl';
import { TripsLayer } from '@deck.gl/geo-layers';
import { PathLayer, ScatterplotLayer } from '@deck.gl/layers';
import { MapboxOverlay } from '@deck.gl/mapbox';
import type { ActiveTrack, NormalizedTrip, TrackPoint } from '../../../entities/vesselTrack/model/types';
import {
normalizeTrip,
buildTrackLineGeoJson,
buildTrackPointsGeoJson,
getTrackTimeRange,
} from '../../../entities/vesselTrack/lib/buildTrackGeoJson';
import { getTrackLineTooltipHtml, getTrackPointTooltipHtml } from '../lib/tooltips';
import { useNativeMapLayers, type NativeLayerSpec, type NativeSourceConfig } from './useNativeMapLayers';
import type { MapProjectionId } from '../types';
/* ── Constants ──────────────────────────────────────────────────────── */
const TRACK_COLOR: [number, number, number] = [0, 224, 255]; // cyan
const TRACK_COLOR_CSS = `rgb(${TRACK_COLOR.join(',')})`;
// Globe 네이티브 레이어/소스 ID
const LINE_SRC = 'vessel-track-line-src';
const PTS_SRC = 'vessel-track-pts-src';
const LINE_ID = 'vessel-track-line';
const ARROW_ID = 'vessel-track-arrow';
const HITAREA_ID = 'vessel-track-line-hitarea';
const PTS_ID = 'vessel-track-pts';
const PTS_HL_ID = 'vessel-track-pts-highlight';
// Mercator Deck.gl 레이어 ID
const DECK_PATH_ID = 'vessel-track-path';
const DECK_TRIPS_ID = 'vessel-track-trips';
const DECK_POINTS_ID = 'vessel-track-deck-pts';
/* ── Globe 네이티브 레이어 스펙 ────────────────────────────────────── */
const GLOBE_LAYERS: NativeLayerSpec[] = [
{
id: LINE_ID,
type: 'line',
sourceId: LINE_SRC,
paint: {
'line-color': TRACK_COLOR_CSS,
'line-width': ['interpolate', ['linear'], ['zoom'], 2, 2, 6, 3, 10, 4],
'line-opacity': 0.8,
},
layout: { 'line-cap': 'round', 'line-join': 'round' },
},
{
id: HITAREA_ID,
type: 'line',
sourceId: LINE_SRC,
paint: { 'line-color': 'rgba(0,0,0,0)', 'line-width': 14, 'line-opacity': 0 },
layout: { 'line-cap': 'round', 'line-join': 'round' },
},
{
id: ARROW_ID,
type: 'symbol',
sourceId: LINE_SRC,
paint: {
'text-color': TRACK_COLOR_CSS,
'text-opacity': 0.7,
},
layout: {
'symbol-placement': 'line',
'text-field': '▶',
'text-size': 10,
'symbol-spacing': 80,
'text-rotation-alignment': 'map',
'text-allow-overlap': true,
'text-ignore-placement': true,
},
},
{
id: PTS_ID,
type: 'circle',
sourceId: PTS_SRC,
paint: {
'circle-radius': ['interpolate', ['linear'], ['zoom'], 2, 2, 6, 3, 10, 5],
'circle-color': TRACK_COLOR_CSS,
'circle-stroke-width': 1,
'circle-stroke-color': 'rgba(0,0,0,0.5)',
'circle-opacity': 0.85,
},
},
{
id: PTS_HL_ID,
type: 'circle',
sourceId: PTS_SRC,
paint: {
'circle-radius': ['interpolate', ['linear'], ['zoom'], 2, 6, 6, 8, 10, 12],
'circle-color': '#ffffff',
'circle-stroke-width': 2,
'circle-stroke-color': TRACK_COLOR_CSS,
'circle-opacity': 0,
},
filter: ['==', ['get', 'index'], -1],
},
];
/* ── Animation speed: 전체 궤적을 ~20초에 재생 ────────────────────── */
const ANIM_CYCLE_SEC = 20;
/* ── Hook ──────────────────────────────────────────────────────────── */
/** @deprecated trackReplay store 엔진으로 이관 완료. 유지보수 호환 용도로만 남겨둔다. */
export function useVesselTrackLayer(
mapRef: MutableRefObject<maplibregl.Map | null>,
overlayRef: MutableRefObject<MapboxOverlay | null>,
projectionBusyRef: MutableRefObject<boolean>,
reorderGlobeFeatureLayers: () => void,
opts: {
activeTrack: ActiveTrack | null;
projection: MapProjectionId;
mapSyncEpoch: number;
},
) {
const { activeTrack, projection, mapSyncEpoch } = opts;
/* ── 정규화 데이터 ── */
const normalizedTrip = useMemo<NormalizedTrip | null>(() => {
if (!activeTrack || activeTrack.points.length < 2) return null;
return normalizeTrip(activeTrack, TRACK_COLOR);
}, [activeTrack]);
const timeRange = useMemo(() => {
if (!normalizedTrip) return null;
return getTrackTimeRange(normalizedTrip);
}, [normalizedTrip]);
/* ── Globe 네이티브 GeoJSON ── */
const lineGeoJson = useMemo(() => {
if (!activeTrack || activeTrack.points.length < 2) return null;
return buildTrackLineGeoJson(activeTrack);
}, [activeTrack]);
const pointsGeoJson = useMemo(() => {
if (!activeTrack || activeTrack.points.length === 0) return null;
return buildTrackPointsGeoJson(activeTrack);
}, [activeTrack]);
/* ── Globe 네이티브 레이어 (useNativeMapLayers) ── */
const globeSources = useMemo<NativeSourceConfig[]>(() => [
{ id: LINE_SRC, data: lineGeoJson, options: { lineMetrics: true } },
{ id: PTS_SRC, data: pointsGeoJson },
], [lineGeoJson, pointsGeoJson]);
const isGlobeVisible = projection === 'globe' && activeTrack != null && activeTrack.points.length >= 2;
useNativeMapLayers(
mapRef,
projectionBusyRef,
reorderGlobeFeatureLayers,
{
sources: globeSources,
layers: GLOBE_LAYERS,
visible: isGlobeVisible,
beforeLayer: ['zones-fill', 'zones-line'],
},
[lineGeoJson, pointsGeoJson, isGlobeVisible, projection, mapSyncEpoch],
);
/* ── Globe 호버 툴팁 ── */
const tooltipRef = useRef<maplibregl.Popup | null>(null);
const clearTooltip = useCallback(() => {
try { tooltipRef.current?.remove(); } catch { /* ignore */ }
tooltipRef.current = null;
}, []);
useEffect(() => {
const map = mapRef.current;
if (!map || projection !== 'globe' || !activeTrack) {
clearTooltip();
return;
}
const onMove = (e: maplibregl.MapMouseEvent) => {
if (projectionBusyRef.current || !map.isStyleLoaded()) {
clearTooltip();
return;
}
const layers = [PTS_ID, HITAREA_ID].filter((id) => map.getLayer(id));
if (layers.length === 0) { clearTooltip(); return; }
let features: maplibregl.MapGeoJSONFeature[] = [];
try {
features = map.queryRenderedFeatures(e.point, { layers });
} catch { /* ignore */ }
if (features.length === 0) {
clearTooltip();
// 하이라이트 리셋
try {
if (map.getLayer(PTS_HL_ID)) {
map.setFilter(PTS_HL_ID, ['==', ['get', 'index'], -1] as never);
map.setPaintProperty(PTS_HL_ID, 'circle-opacity', 0);
}
} catch { /* ignore */ }
return;
}
const feat = features[0];
const props = feat.properties || {};
const layerId = feat.layer?.id;
let tooltipHtml = '';
if (layerId === PTS_ID && props.index != null) {
tooltipHtml = getTrackPointTooltipHtml({
name: String(props.name ?? ''),
sog: Number(props.sog),
cog: Number(props.cog),
heading: Number(props.heading),
status: String(props.status ?? ''),
messageTimestamp: String(props.messageTimestamp ?? ''),
}).html;
// 하이라이트
try {
if (map.getLayer(PTS_HL_ID)) {
map.setFilter(PTS_HL_ID, ['==', ['get', 'index'], Number(props.index)] as never);
map.setPaintProperty(PTS_HL_ID, 'circle-opacity', 0.8);
}
} catch { /* ignore */ }
} else if (layerId === HITAREA_ID) {
tooltipHtml = getTrackLineTooltipHtml({
name: String(props.name ?? ''),
pointCount: Number(props.pointCount ?? 0),
minutes: Number(props.minutes ?? 0),
totalDistanceNm: Number(props.totalDistanceNm ?? 0),
}).html;
}
if (!tooltipHtml) { clearTooltip(); return; }
if (!tooltipRef.current) {
tooltipRef.current = new maplibregl.Popup({
closeButton: false, closeOnClick: false,
maxWidth: '360px', className: 'maplibre-tooltip-popup',
});
}
const container = document.createElement('div');
container.className = 'maplibre-tooltip-popup__content';
container.innerHTML = tooltipHtml;
tooltipRef.current.setLngLat(e.lngLat).setDOMContent(container).addTo(map);
};
const onOut = () => {
clearTooltip();
try {
if (map.getLayer(PTS_HL_ID)) {
map.setFilter(PTS_HL_ID, ['==', ['get', 'index'], -1] as never);
map.setPaintProperty(PTS_HL_ID, 'circle-opacity', 0);
}
} catch { /* ignore */ }
};
map.on('mousemove', onMove);
map.on('mouseout', onOut);
return () => {
map.off('mousemove', onMove);
map.off('mouseout', onOut);
clearTooltip();
};
}, [projection, activeTrack, clearTooltip]);
/* ── Mercator: 정적 레이어 1회 생성 + rAF 애니메이션 (React state 미사용) ── */
const animRef = useRef(0);
useEffect(() => {
const overlay = overlayRef.current;
if (!overlay || projection !== 'mercator') {
cancelAnimationFrame(animRef.current);
return;
}
const isTrackLayer = (id?: string) =>
id === DECK_PATH_ID || id === DECK_TRIPS_ID || id === DECK_POINTS_ID;
if (!normalizedTrip || !activeTrack || activeTrack.points.length < 2 || !timeRange || timeRange.durationSec === 0) {
cancelAnimationFrame(animRef.current);
try {
const existing = (overlay as unknown as { props?: { layers?: unknown[] } }).props?.layers ?? [];
const filtered = (existing as { id?: string }[]).filter((l) => !isTrackLayer(l.id));
if (filtered.length !== (existing as unknown[]).length) {
overlay.setProps({ layers: filtered } as never);
}
} catch { /* ignore */ }
return;
}
// 정적 레이어: activeTrack 변경 시 1회만 생성, rAF 루프에서 재사용
const pathLayer = new PathLayer<NormalizedTrip>({
id: DECK_PATH_ID,
data: [normalizedTrip],
getPath: (d) => d.path,
getColor: [...TRACK_COLOR, 90] as [number, number, number, number],
getWidth: 2,
widthMinPixels: 2,
widthUnits: 'pixels' as const,
capRounded: true,
jointRounded: true,
pickable: false,
});
const sorted = [...activeTrack.points].sort(
(a, b) => new Date(a.messageTimestamp).getTime() - new Date(b.messageTimestamp).getTime(),
);
const pointsLayer = new ScatterplotLayer<TrackPoint>({
id: DECK_POINTS_ID,
data: sorted,
getPosition: (d) => [d.lon, d.lat],
getRadius: 4,
radiusUnits: 'pixels' as const,
getFillColor: TRACK_COLOR,
getLineColor: [0, 0, 0, 128],
lineWidthMinPixels: 1,
stroked: true,
pickable: true,
});
// rAF 루프: TripsLayer만 매 프레임 갱신 (React 재렌더링 없음)
const { minTime, maxTime, durationSec } = timeRange;
const speed = durationSec / ANIM_CYCLE_SEC;
let current = minTime;
const loop = () => {
current += speed / 60;
if (current > maxTime) current = minTime;
const tripsLayer = new TripsLayer({
id: DECK_TRIPS_ID,
data: [normalizedTrip],
getPath: (d: NormalizedTrip) => d.path,
getTimestamps: (d: NormalizedTrip) => d.timestamps,
getColor: (d: NormalizedTrip) => d.color,
currentTime: current,
trailLength: durationSec * 0.15,
fadeTrail: true,
widthMinPixels: 4,
capRounded: true,
jointRounded: true,
pickable: false,
});
try {
const existing = (overlay as unknown as { props?: { layers?: unknown[] } }).props?.layers ?? [];
const filtered = (existing as { id?: string }[]).filter((l) => !isTrackLayer(l.id));
overlay.setProps({ layers: [...filtered, pathLayer, tripsLayer, pointsLayer] } as never);
} catch { /* ignore */ }
animRef.current = requestAnimationFrame(loop);
};
animRef.current = requestAnimationFrame(loop);
return () => cancelAnimationFrame(animRef.current);
}, [projection, normalizedTrip, activeTrack, timeRange]);
/* ── 항적 조회 시 자동 fitBounds ── */
useEffect(() => {
const map = mapRef.current;
if (!map || !activeTrack || activeTrack.points.length < 2) return;
if (projectionBusyRef.current) return;
let minLon = Infinity;
let minLat = Infinity;
let maxLon = -Infinity;
let maxLat = -Infinity;
for (const pt of activeTrack.points) {
if (pt.lon < minLon) minLon = pt.lon;
if (pt.lat < minLat) minLat = pt.lat;
if (pt.lon > maxLon) maxLon = pt.lon;
if (pt.lat > maxLat) maxLat = pt.lat;
}
const fitOpts = { padding: 80, duration: 1000, maxZoom: 14 };
const apply = () => {
try {
map.fitBounds([[minLon, minLat], [maxLon, maxLat]], fitOpts);
} catch { /* ignore */ }
};
if (map.isStyleLoaded()) {
apply();
} else {
const onLoad = () => { apply(); map.off('styledata', onLoad); };
map.on('styledata', onLoad);
return () => { map.off('styledata', onLoad); };
}
}, [activeTrack]);
}

파일 보기

@ -1,4 +1,4 @@
import { useEffect, type MutableRefObject } from 'react'; import { useEffect, useMemo, type MutableRefObject } from 'react';
import maplibregl, { import maplibregl, {
type GeoJSONSource, type GeoJSONSource,
type GeoJSONSourceSpecification, type GeoJSONSourceSpecification,
@ -10,6 +10,34 @@ import type { ZonesGeoJson } from '../../../entities/zone/api/useZones';
import type { MapToggleState } from '../../../features/mapToggles/MapToggles'; import type { MapToggleState } from '../../../features/mapToggles/MapToggles';
import type { BaseMapId, MapProjectionId } from '../types'; import type { BaseMapId, MapProjectionId } from '../types';
import { kickRepaint, onMapStyleReady } from '../lib/mapCore'; import { kickRepaint, onMapStyleReady } from '../lib/mapCore';
import { guardedSetVisibility } from '../lib/layerHelpers';
/** Globe tessellation vertex 65535 .
* ( 2100+ vertex) globe에서 70,000+
* ring당 maxPts개로 . centroid에는 . */
function simplifyZonesForGlobe(zones: ZonesGeoJson): ZonesGeoJson {
const MAX_PTS = 60;
const subsample = (ring: GeoJSON.Position[]): GeoJSON.Position[] => {
if (ring.length <= MAX_PTS) return ring;
const step = Math.ceil(ring.length / MAX_PTS);
const out: GeoJSON.Position[] = [ring[0]];
for (let i = step; i < ring.length - 1; i += step) out.push(ring[i]);
out.push(ring[0]); // close ring
return out;
};
return {
...zones,
features: zones.features.map((f) => ({
...f,
geometry: {
...f.geometry,
coordinates: f.geometry.coordinates.map((polygon) =>
polygon.map((ring) => subsample(ring)),
),
},
})),
};
}
export function useZonesLayer( export function useZonesLayer(
mapRef: MutableRefObject<maplibregl.Map | null>, mapRef: MutableRefObject<maplibregl.Map | null>,
@ -26,6 +54,12 @@ export function useZonesLayer(
) { ) {
const { zones, overlays, projection, baseMap, hoveredZoneId, mapSyncEpoch } = opts; const { zones, overlays, projection, baseMap, hoveredZoneId, mapSyncEpoch } = opts;
// globe용 간소화 데이터를 미리 캐싱 — ensure() 내 매번 재계산 방지
const simplifiedZones = useMemo(
() => (zones ? simplifyZonesForGlobe(zones) : null),
[zones],
);
useEffect(() => { useEffect(() => {
const map = mapRef.current; const map = mapRef.current;
if (!map) return; if (!map) return;
@ -47,33 +81,31 @@ export function useZonesLayer(
zoneLabelExpr.push(['coalesce', ['get', 'zoneName'], ['get', 'zoneLabel'], ['get', 'NAME'], '수역']); zoneLabelExpr.push(['coalesce', ['get', 'zoneName'], ['get', 'zoneLabel'], ['get', 'NAME'], '수역']);
const ensure = () => { const ensure = () => {
if (projectionBusyRef.current) return; // 소스 데이터 간소화 — projectionBusy 중에도 실행해야 함
const visibility = overlays.zones ? 'visible' : 'none'; // globe 전환 시 projectionBusy 가드 뒤에서만 실행하면 MapLibre가
// 원본(2100+ vertex) 데이터로 globe tessellation → 73,000+ vertex → 노란 막대
const sourceData = projection === 'globe' ? simplifiedZones : zones;
if (sourceData) {
try { try {
if (map.getLayer(fillId)) map.setLayoutProperty(fillId, 'visibility', visibility); const existing = map.getSource(srcId) as GeoJSONSource | undefined;
} catch { if (existing) existing.setData(sourceData);
// ignore } catch { /* ignore — source may not exist yet */ }
}
try {
if (map.getLayer(lineId)) map.setLayoutProperty(lineId, 'visibility', visibility);
} catch {
// ignore
}
try {
if (map.getLayer(labelId)) map.setLayoutProperty(labelId, 'visibility', visibility);
} catch {
// ignore
} }
const visibility: 'visible' | 'none' = overlays.zones ? 'visible' : 'none';
guardedSetVisibility(map, fillId, visibility);
guardedSetVisibility(map, lineId, visibility);
guardedSetVisibility(map, labelId, visibility);
if (projectionBusyRef.current) return;
if (!zones) return; if (!zones) return;
if (!map.isStyleLoaded()) return; if (!map.isStyleLoaded()) return;
try { try {
const existing = map.getSource(srcId) as GeoJSONSource | undefined; // 소스가 아직 없으면 생성 (setData는 위에서 이미 처리됨)
if (existing) { if (!map.getSource(srcId)) {
existing.setData(zones); const data = projection === 'globe' ? simplifiedZones ?? zones : zones;
} else { map.addSource(srcId, { type: 'geojson', data: data! } as GeoJSONSourceSpecification);
map.addSource(srcId, { type: 'geojson', data: zones } as GeoJSONSourceSpecification);
} }
const style = map.getStyle(); const style = map.getStyle();
@ -226,5 +258,5 @@ export function useZonesLayer(
return () => { return () => {
stop(); stop();
}; };
}, [zones, overlays.zones, projection, baseMap, hoveredZoneId, mapSyncEpoch, reorderGlobeFeatureLayers]); }, [zones, simplifiedZones, overlays.zones, projection, baseMap, hoveredZoneId, mapSyncEpoch, reorderGlobeFeatureLayers]);
} }

파일 보기

@ -11,10 +11,53 @@ export const SHALLOW_WATER_LINE_DEFAULT = '#114f5c';
const BATHY_ZOOM_RANGES: BathyZoomRange[] = [ const BATHY_ZOOM_RANGES: BathyZoomRange[] = [
{ id: 'bathymetry-fill', mercator: [3, 24], globe: [3, 24] }, { id: 'bathymetry-fill', mercator: [3, 24], globe: [3, 24] },
{ id: 'bathymetry-borders', mercator: [5, 24], globe: [5, 24] }, { id: 'bathymetry-borders-major', mercator: [3, 7], globe: [3, 7] },
{ id: 'bathymetry-borders-major', mercator: [3, 24], globe: [3, 24] }, { id: 'bathymetry-borders', mercator: [7, 24], globe: [7, 24] },
{ id: 'bathymetry-lines-coarse', mercator: [5, 7], globe: [5, 7] },
{ id: 'bathymetry-lines-major', mercator: [7, 9], globe: [7, 9] },
{ id: 'bathymetry-lines-detail', mercator: [9, 24], globe: [9, 24] },
{ id: 'bathymetry-labels-coarse', mercator: [6, 9], globe: [6, 9] },
{ id: 'bathymetry-labels', mercator: [9, 24], globe: [9, 24] },
]; ];
/**
* LOD vertex가 minzoom을
* , .
* .
*/
function applyLandLayerLOD(style: StyleSpecification): void {
if (!style.layers || !Array.isArray(style.layers)) return;
// source-layer → 렌더링을 시작할 최소 줌 레벨
// globe 모드 줌아웃 시 vertex 65535 초과로 GPU 렌더링 아티팩트(노란 막대) 방지
const LOD_MINZOOM: Record<string, number> = {
'landcover': 9,
'globallandcover': 9,
'landuse': 11,
'boundary': 5,
'transportation': 8,
'transportation_name': 10,
'building': 14,
'housenumber': 16,
'aeroway': 11,
'park': 10,
'mountain_peak': 11,
};
for (const layer of style.layers as unknown as LayerSpecification[]) {
const spec = layer as Record<string, unknown>;
const sourceLayer = spec['source-layer'] as string | undefined;
if (!sourceLayer) continue;
const lodMin = LOD_MINZOOM[sourceLayer];
if (lodMin === undefined) continue;
// 기존 minzoom보다 높을 때만 덮어씀
const current = (spec.minzoom as number) ?? 0;
if (lodMin > current) {
spec.minzoom = lodMin;
}
}
}
export function injectOceanBathymetryLayers(style: StyleSpecification, maptilerKey: string) { export function injectOceanBathymetryLayers(style: StyleSpecification, maptilerKey: string) {
const oceanSourceId = 'maptiler-ocean'; const oceanSourceId = 'maptiler-ocean';
@ -31,17 +74,13 @@ export function injectOceanBathymetryLayers(style: StyleSpecification, maptilerK
const depth = ['to-number', ['get', 'depth']] as unknown as number[]; const depth = ['to-number', ['get', 'depth']] as unknown as number[];
const depthLabel = ['concat', ['to-string', ['*', depth, -1]], 'm'] as unknown as string[]; const depthLabel = ['concat', ['to-string', ['*', depth, -1]], 'm'] as unknown as string[];
// Bug #3 fix: shallow depths now use brighter teal tones to distinguish from deep ocean // 수심 색상: 전체 범위 (-8000m ~ 0m)
const bathyFillColor = [ const bathyFillColor = [
'interpolate', 'interpolate',
['linear'], ['linear'],
depth, depth,
-11000,
'#00040b',
-8000, -8000,
'#010610', '#010610',
-6000,
'#020816',
-4000, -4000,
'#030c1c', '#030c1c',
-2000, -2000,
@ -64,6 +103,15 @@ export function injectOceanBathymetryLayers(style: StyleSpecification, maptilerK
'#2097a6', '#2097a6',
] as const; ] as const;
// --- Depth tiers for zoom-based LOD ---
// 줌 기반 LOD로 vertex 제어 — 줌아웃에선 주요 등심선만, 줌인에서 점진적 디테일
const DEPTHS_COARSE = [-1000, -2000];
const DEPTHS_MEDIUM = [-100, -500, -1000, -2000, -4000];
const DEPTHS_DETAIL = [-50, -100, -200, -500, -1000, -2000, -4000, -8000];
const depthIn = (depths: number[]) =>
['in', depth, ['literal', depths]] as unknown[];
// === Fill (contour polygons) — 단일 레이어, 전체 depth ===
const bathyFill: LayerSpecification = { const bathyFill: LayerSpecification = {
id: 'bathymetry-fill', id: 'bathymetry-fill',
type: 'fill', type: 'fill',
@ -77,98 +125,132 @@ export function injectOceanBathymetryLayers(style: StyleSpecification, maptilerK
}, },
} as unknown as LayerSpecification; } as unknown as LayerSpecification;
const bathyBandBorders: LayerSpecification = { // === Borders (contour polygon edges) — 2-tier LOD ===
// z3-z7: 1000m, 2000m 경계만
const bathyBordersMajor: LayerSpecification = {
id: 'bathymetry-borders-major',
type: 'line',
source: oceanSourceId,
'source-layer': 'contour',
minzoom: 3,
maxzoom: 7,
filter: depthIn(DEPTHS_COARSE) as unknown as unknown[],
paint: {
'line-color': 'rgba(255,255,255,0.14)',
'line-opacity': ['interpolate', ['linear'], ['zoom'], 3, 0.10, 6, 0.16],
'line-blur': ['interpolate', ['linear'], ['zoom'], 3, 0.5, 6, 0.35],
'line-width': ['interpolate', ['linear'], ['zoom'], 3, 0.25, 6, 0.4],
},
} as unknown as LayerSpecification;
// z7+: 전체 등심선 경계
const bathyBorders: LayerSpecification = {
id: 'bathymetry-borders', id: 'bathymetry-borders',
type: 'line', type: 'line',
source: oceanSourceId, source: oceanSourceId,
'source-layer': 'contour', 'source-layer': 'contour',
minzoom: 5, // fill은 3부터, borders는 5부터 minzoom: 7,
maxzoom: 24, maxzoom: 24,
paint: { paint: {
'line-color': 'rgba(255,255,255,0.06)', 'line-color': 'rgba(255,255,255,0.06)',
'line-opacity': ['interpolate', ['linear'], ['zoom'], 4, 0.12, 8, 0.18, 12, 0.22], 'line-opacity': ['interpolate', ['linear'], ['zoom'], 7, 0.18, 12, 0.22],
'line-blur': ['interpolate', ['linear'], ['zoom'], 4, 0.8, 10, 0.2], 'line-blur': ['interpolate', ['linear'], ['zoom'], 7, 0.3, 10, 0.2],
'line-width': ['interpolate', ['linear'], ['zoom'], 4, 0.2, 8, 0.35, 12, 0.6], 'line-width': ['interpolate', ['linear'], ['zoom'], 7, 0.35, 12, 0.6],
}, },
} as unknown as LayerSpecification; } as unknown as LayerSpecification;
const bathyLinesMinor: LayerSpecification = { // === Contour lines (contour_line) — 3-tier LOD ===
id: 'bathymetry-lines', // z5-z7: 1000m, 2000m만
const bathyLinesCoarse: LayerSpecification = {
id: 'bathymetry-lines-coarse',
type: 'line', type: 'line',
source: oceanSourceId, source: oceanSourceId,
'source-layer': 'contour_line', 'source-layer': 'contour_line',
minzoom: 7, minzoom: 5,
maxzoom: 7,
filter: depthIn(DEPTHS_COARSE) as unknown as unknown[],
paint: { paint: {
'line-color': [ 'line-color': 'rgba(255,255,255,0.12)',
'interpolate', 'line-opacity': ['interpolate', ['linear'], ['zoom'], 5, 0.15, 7, 0.22],
['linear'], 'line-blur': 0.5,
depth, 'line-width': ['interpolate', ['linear'], ['zoom'], 5, 0.4, 7, 0.6],
-11000,
'rgba(255,255,255,0.04)',
-6000,
'rgba(255,255,255,0.05)',
-2000,
'rgba(255,255,255,0.07)',
0,
'rgba(255,255,255,0.10)',
],
'line-opacity': ['interpolate', ['linear'], ['zoom'], 8, 0.18, 10, 0.22, 12, 0.28],
'line-blur': ['interpolate', ['linear'], ['zoom'], 8, 0.8, 11, 0.3],
'line-width': ['interpolate', ['linear'], ['zoom'], 8, 0.35, 10, 0.55, 12, 0.85],
}, },
} as unknown as LayerSpecification; } as unknown as LayerSpecification;
const majorDepths = [-50, -100, -200, -500, -1000, -2000, -4000, -6000, -8000, -9500]; // z7-z9: 100, 500, 1000, 2000, 4000m
const bathyMajorDepthFilter: unknown[] = [
'in',
['to-number', ['get', 'depth']],
['literal', majorDepths],
] as unknown[];
const bathyLinesMajor: LayerSpecification = { const bathyLinesMajor: LayerSpecification = {
id: 'bathymetry-lines-major', id: 'bathymetry-lines-major',
type: 'line', type: 'line',
source: oceanSourceId, source: oceanSourceId,
'source-layer': 'contour_line', 'source-layer': 'contour_line',
minzoom: 7, minzoom: 7,
maxzoom: 24, maxzoom: 9,
filter: bathyMajorDepthFilter as unknown as unknown[], filter: depthIn(DEPTHS_MEDIUM) as unknown as unknown[],
paint: { paint: {
'line-color': 'rgba(255,255,255,0.16)', 'line-color': 'rgba(255,255,255,0.16)',
'line-opacity': ['interpolate', ['linear'], ['zoom'], 8, 0.22, 10, 0.28, 12, 0.34], 'line-opacity': ['interpolate', ['linear'], ['zoom'], 7, 0.22, 9, 0.28],
'line-blur': ['interpolate', ['linear'], ['zoom'], 8, 0.4, 11, 0.2], 'line-blur': ['interpolate', ['linear'], ['zoom'], 7, 0.4, 9, 0.2],
'line-width': ['interpolate', ['linear'], ['zoom'], 8, 0.6, 10, 0.95, 12, 1.3], 'line-width': ['interpolate', ['linear'], ['zoom'], 7, 0.6, 9, 0.95],
}, },
} as unknown as LayerSpecification; } as unknown as LayerSpecification;
const bathyBandBordersMajor: LayerSpecification = { // z9+: 50~8000m (풀 디테일)
id: 'bathymetry-borders-major', const bathyLinesDetail: LayerSpecification = {
id: 'bathymetry-lines-detail',
type: 'line', type: 'line',
source: oceanSourceId, source: oceanSourceId,
'source-layer': 'contour', 'source-layer': 'contour_line',
minzoom: 3, minzoom: 9,
maxzoom: 24, maxzoom: 24,
filter: bathyMajorDepthFilter as unknown as unknown[], filter: depthIn(DEPTHS_DETAIL) as unknown as unknown[],
paint: { paint: {
'line-color': 'rgba(255,255,255,0.14)', 'line-color': 'rgba(255,255,255,0.16)',
'line-opacity': ['interpolate', ['linear'], ['zoom'], 3, 0.10, 6, 0.16, 8, 0.2, 12, 0.26], 'line-opacity': ['interpolate', ['linear'], ['zoom'], 9, 0.28, 12, 0.34],
'line-blur': ['interpolate', ['linear'], ['zoom'], 3, 0.5, 6, 0.35, 10, 0.15], 'line-blur': ['interpolate', ['linear'], ['zoom'], 9, 0.2, 11, 0.15],
'line-width': ['interpolate', ['linear'], ['zoom'], 3, 0.25, 6, 0.4, 8, 0.55, 12, 0.85], 'line-width': ['interpolate', ['linear'], ['zoom'], 9, 0.95, 12, 1.3],
}, },
} as unknown as LayerSpecification; } as unknown as LayerSpecification;
// === Labels — 2-tier LOD ===
// z6-z9: 1000m, 2000m 라벨만
const bathyLabelsCoarse: LayerSpecification = {
id: 'bathymetry-labels-coarse',
type: 'symbol',
source: oceanSourceId,
'source-layer': 'contour_line',
minzoom: 6,
maxzoom: 9,
filter: depthIn(DEPTHS_COARSE) as unknown as unknown[],
layout: {
'symbol-placement': 'line',
'text-field': depthLabel,
'text-font': ['Noto Sans Regular', 'Open Sans Regular'],
'text-size': ['interpolate', ['linear'], ['zoom'], 6, 10, 9, 12],
'text-allow-overlap': false,
'text-padding': 4,
'text-rotation-alignment': 'map',
},
paint: {
'text-color': 'rgba(226,232,240,0.78)',
'text-halo-color': 'rgba(2,6,23,0.88)',
'text-halo-width': 1.2,
'text-halo-blur': 0.5,
},
} as unknown as LayerSpecification;
// z9+: 100~4000m 라벨
const bathyLabels: LayerSpecification = { const bathyLabels: LayerSpecification = {
id: 'bathymetry-labels', id: 'bathymetry-labels',
type: 'symbol', type: 'symbol',
source: oceanSourceId, source: oceanSourceId,
'source-layer': 'contour_line', 'source-layer': 'contour_line',
minzoom: 7, minzoom: 9,
filter: bathyMajorDepthFilter as unknown as unknown[], filter: depthIn(DEPTHS_MEDIUM) as unknown as unknown[],
layout: { layout: {
'symbol-placement': 'line', 'symbol-placement': 'line',
'text-field': depthLabel, 'text-field': depthLabel,
'text-font': ['Noto Sans Regular', 'Open Sans Regular'], 'text-font': ['Noto Sans Regular', 'Open Sans Regular'],
'text-size': ['interpolate', ['linear'], ['zoom'], 7, 10, 9, 12, 11, 14, 13, 16], 'text-size': ['interpolate', ['linear'], ['zoom'], 9, 12, 11, 14, 13, 16],
'text-allow-overlap': false, 'text-allow-overlap': false,
'text-padding': 4, 'text-padding': 4,
'text-rotation-alignment': 'map', 'text-rotation-alignment': 'map',
@ -244,10 +326,12 @@ export function injectOceanBathymetryLayers(style: StyleSpecification, maptilerK
const toInsert = [ const toInsert = [
bathyFill, bathyFill,
bathyBandBorders, bathyBordersMajor,
bathyBandBordersMajor, bathyBorders,
bathyLinesMinor, bathyLinesCoarse,
bathyLinesMajor, bathyLinesMajor,
bathyLinesDetail,
bathyLabelsCoarse,
bathyLabels, bathyLabels,
landformLabels, landformLabels,
].filter((l) => !existingIds.has(l.id)); ].filter((l) => !existingIds.has(l.id));
@ -273,6 +357,7 @@ export function applyBathymetryZoomProfile(map: maplibregl.Map, baseMap: BaseMap
// ignore // ignore
} }
} }
} }
function applyKoreanLabels(style: StyleSpecification) { function applyKoreanLabels(style: StyleSpecification) {
@ -298,6 +383,7 @@ export async function resolveInitialMapStyle(signal: AbortSignal): Promise<strin
const res = await fetch(styleUrl, { signal, headers: { accept: 'application/json' } }); const res = await fetch(styleUrl, { signal, headers: { accept: 'application/json' } });
if (!res.ok) throw new Error(`MapTiler style fetch failed: ${res.status} ${res.statusText}`); if (!res.ok) throw new Error(`MapTiler style fetch failed: ${res.status} ${res.statusText}`);
const json = (await res.json()) as StyleSpecification; const json = (await res.json()) as StyleSpecification;
applyLandLayerLOD(json);
applyKoreanLabels(json); applyKoreanLabels(json);
injectOceanBathymetryLayers(json, key); injectOceanBathymetryLayers(json, key);
return json; return json;

파일 보기

@ -38,20 +38,19 @@ export function destinationPointLngLat(
return [outLon, outLat]; return [outLon, outLat];
} }
export function circleRingLngLat(center: [number, number], radiusMeters: number, steps = 72): [number, number][] { export function circleRingLngLat(center: [number, number], radiusMeters: number, steps = 36): [number, number][] {
const [lon0, lat0] = center; // 반경이 지구 둘레의 1/4 (≈10,000km)을 넘으면 클램핑
const latRad = lat0 * DEG2RAD; const r = clampNumber(radiusMeters, 0, EARTH_RADIUS_M * Math.PI * 0.5);
const cosLat = Math.max(1e-6, Math.cos(latRad));
const r = Math.max(0, radiusMeters);
const ring: [number, number][] = []; const ring: [number, number][] = [];
for (let i = 0; i <= steps; i++) { for (let i = 0; i <= steps; i++) {
const a = (i / steps) * Math.PI * 2; const a = (i / steps) * Math.PI * 2;
const dy = r * Math.sin(a); const pt = destinationPointLngLat(center, a * RAD2DEG, r);
const dx = r * Math.cos(a); ring.push(pt);
const dLat = (dy / EARTH_RADIUS_M) / DEG2RAD; }
const dLon = (dx / (EARTH_RADIUS_M * cosLat)) / DEG2RAD; // 고리 닫기 보정
ring.push([lon0 + dLon, lat0 + dLat]); if (ring.length > 1) {
ring[ring.length - 1] = ring[0];
} }
return ring; return ring;
} }

파일 보기

@ -24,17 +24,10 @@ export function removeSourceIfExists(map: maplibregl.Map, sourceId: string) {
} }
} }
// Ship 레이어/소스는 useGlobeShips에서 visibility 토글로 관리 (재생성 비용 회피)
const GLOBE_NATIVE_LAYER_IDS = [ const GLOBE_NATIVE_LAYER_IDS = [
'ships-globe-halo',
'ships-globe-outline',
'ships-globe',
'ships-globe-label',
'ships-globe-hover-halo',
'ships-globe-hover-outline',
'ships-globe-hover',
'pair-lines-ml', 'pair-lines-ml',
'fc-lines-ml', 'fc-lines-ml',
'fleet-circles-ml-fill',
'fleet-circles-ml', 'fleet-circles-ml',
'pair-range-ml', 'pair-range-ml',
'subcables-hitarea', 'subcables-hitarea',
@ -43,19 +36,23 @@ const GLOBE_NATIVE_LAYER_IDS = [
'subcables-glow', 'subcables-glow',
'subcables-points', 'subcables-points',
'subcables-label', 'subcables-label',
'vessel-track-line',
'vessel-track-line-hitarea',
'vessel-track-arrow',
'vessel-track-pts',
'vessel-track-pts-highlight',
'deck-globe', 'deck-globe',
]; ];
const GLOBE_NATIVE_SOURCE_IDS = [ const GLOBE_NATIVE_SOURCE_IDS = [
'ships-globe-src',
'ships-globe-hover-src',
'pair-lines-ml-src', 'pair-lines-ml-src',
'fc-lines-ml-src', 'fc-lines-ml-src',
'fleet-circles-ml-src', 'fleet-circles-ml-src',
'fleet-circles-ml-fill-src',
'pair-range-ml-src', 'pair-range-ml-src',
'subcables-src', 'subcables-src',
'subcables-pts-src', 'subcables-pts-src',
'vessel-track-line-src',
'vessel-track-pts-src',
]; ];
export function clearGlobeNativeLayers(map: maplibregl.Map) { export function clearGlobeNativeLayers(map: maplibregl.Map) {
@ -104,6 +101,22 @@ export function setLayerVisibility(map: maplibregl.Map, layerId: string, visible
} }
} }
/**
* setLayoutProperty('visibility') wrapper .
* MapLibre는 setLayoutProperty style._changed = true를
* symbol layer의 placement를 . text-allow-overlap:false
* , .
*/
export function guardedSetVisibility(map: maplibregl.Map, layerId: string, target: 'visible' | 'none') {
if (!map.getLayer(layerId)) return;
try {
if (map.getLayoutProperty(layerId, 'visibility') === target) return;
map.setLayoutProperty(layerId, 'visibility', target);
} catch {
// ignore
}
}
export function cleanupLayers( export function cleanupLayers(
map: maplibregl.Map, map: maplibregl.Map,
layerIds: string[], layerIds: string[],

파일 보기

@ -103,6 +103,19 @@ export function sanitizeDeckLayerList(value: unknown): unknown[] {
let dropped = 0; let dropped = 0;
for (const layer of value) { for (const layer of value) {
// Deck layer instances expose `id`, `props`, and `clone`.
// Filter out MapLibre native layer specs that only share an `id`.
const isDeckLayerLike =
!!layer &&
typeof layer === 'object' &&
typeof (layer as { id?: unknown }).id === 'string' &&
typeof (layer as { clone?: unknown }).clone === 'function' &&
typeof (layer as { props?: unknown }).props === 'object';
if (!isDeckLayerLike) {
dropped += 1;
continue;
}
const layerId = getLayerId(layer); const layerId = getLayerId(layer);
if (!layerId) { if (!layerId) {
dropped += 1; dropped += 1;

파일 보기

@ -0,0 +1,30 @@
/**
* Ship SVG fetch하여 data URL로 .
* Deck.gl IconLayer가 iconAtlas URL을 fetch하지
* data URL을 .
*/
const SHIP_SVG_URL = '/assets/ship.svg';
let _cachedDataUrl: string | null = null;
let _promise: Promise<string> | null = null;
function preloadShipIcon(): Promise<string> {
if (_cachedDataUrl) return Promise.resolve(_cachedDataUrl);
if (_promise) return _promise;
_promise = fetch(SHIP_SVG_URL)
.then((res) => res.text())
.then((svg) => {
_cachedDataUrl = `data:image/svg+xml;base64,${btoa(svg)}`;
return _cachedDataUrl;
})
.catch(() => SHIP_SVG_URL);
return _promise;
}
/** 캐시된 data URL 또는 폴백 URL 반환 */
export function getCachedShipIcon(): string {
return _cachedDataUrl ?? SHIP_SVG_URL;
}
// 모듈 임포트 시 즉시 로드 시작
preloadShipIcon();

파일 보기

@ -63,10 +63,11 @@ export function getGlobeBaseShipColor({
if (rgb) return rgbToHex(lightenColor(rgb, 0.38)); if (rgb) return rgbToHex(lightenColor(rgb, 0.38));
} }
if (!isFiniteNumber(sog)) return 'rgba(100,116,139,0.55)'; // Keep alpha control in icon-opacity only to avoid double-multiplying transparency.
if (sog >= 10) return 'rgba(148,163,184,0.78)'; if (!isFiniteNumber(sog)) return '#64748b';
if (sog >= 1) return 'rgba(100,116,139,0.74)'; if (sog >= 10) return '#94a3b8';
return 'rgba(71,85,105,0.68)'; if (sog >= 1) return '#64748b';
return '#475569';
} }
export function getShipColor( export function getShipColor(

파일 보기

@ -1,5 +1,6 @@
import type { AisTarget } from '../../../entities/aisTarget/model/types'; import type { AisTarget } from '../../../entities/aisTarget/model/types';
import type { LegacyVesselInfo } from '../../../entities/legacyVessel/model/types'; import type { LegacyVesselInfo } from '../../../entities/legacyVessel/model/types';
import { fmtIsoFull } from '../../../shared/lib/datetime';
import { isFiniteNumber, toSafeNumber } from './setUtils'; import { isFiniteNumber, toSafeNumber } from './setUtils';
export function formatNm(value: number | null | undefined) { export function formatNm(value: number | null | undefined) {
@ -54,7 +55,7 @@ export function getShipTooltipHtml({
<div style="font-weight: 700; margin-bottom: 4px;">${name}</div> <div style="font-weight: 700; margin-bottom: 4px;">${name}</div>
<div>MMSI: <b>${mmsi}</b>${vesselType ? ` · ${vesselType}` : ''}</div> <div>MMSI: <b>${mmsi}</b>${vesselType ? ` · ${vesselType}` : ''}</div>
<div>SOG: <b>${sog ?? '?'}</b> kt · COG: <b>${cog ?? '?'}</b>°</div> <div>SOG: <b>${sog ?? '?'}</b> kt · COG: <b>${cog ?? '?'}</b>°</div>
${msg ? `<div style="opacity:.7; font-size: 11px; margin-top: 4px;">${msg}</div>` : ''} ${msg ? `<div style="opacity:.7; font-size: 11px; margin-top: 4px;">${fmtIsoFull(msg)}</div>` : ''}
${legacyHtml} ${legacyHtml}
</div>`, </div>`,
}; };
@ -167,3 +168,54 @@ export function getFleetCircleTooltipHtml({
</div>`, </div>`,
}; };
} }
function fmtMinutesKr(minutes: number): string {
if (minutes < 60) return `${minutes}`;
if (minutes < 1440) return `${Math.round(minutes / 60)}시간`;
return `${Math.round(minutes / 1440)}`;
}
export function getTrackLineTooltipHtml({
name,
pointCount,
minutes,
totalDistanceNm,
}: {
name: string;
pointCount: number;
minutes: number;
totalDistanceNm: number;
}) {
return {
html: `<div style="font-family: system-ui; font-size: 12px;">
<div style="font-weight: 700; margin-bottom: 4px;"> · ${name}</div>
<div>: <b>${fmtMinutesKr(minutes)}</b> · : <b>${pointCount}</b></div>
<div> : <b>${totalDistanceNm.toFixed(1)} NM</b></div>
</div>`,
};
}
export function getTrackPointTooltipHtml({
name,
sog,
cog,
heading,
status,
messageTimestamp,
}: {
name: string;
sog: number;
cog: number;
heading: number;
status: string;
messageTimestamp: string;
}) {
return {
html: `<div style="font-family: system-ui; font-size: 12px; white-space: nowrap;">
<div style="font-weight: 700; margin-bottom: 4px;">${name}</div>
<div>SOG: <b>${isFiniteNumber(sog) ? sog : '?'}</b> kt · COG: <b>${isFiniteNumber(cog) ? cog : '?'}</b>°</div>
<div>Heading: <b>${isFiniteNumber(heading) ? heading : '?'}</b>° · 상태: ${status || '-'}</div>
<div style="opacity:.7; font-size: 11px; margin-top: 4px;">${fmtIsoFull(messageTimestamp)}</div>
</div>`,
};
}

파일 보기

@ -1,6 +1,7 @@
import type { AisTarget } from '../../entities/aisTarget/model/types'; import type { AisTarget } from '../../entities/aisTarget/model/types';
import type { LegacyVesselInfo } from '../../entities/legacyVessel/model/types'; import type { LegacyVesselInfo } from '../../entities/legacyVessel/model/types';
import type { SubcableGeoJson } from '../../entities/subcable/model/types'; import type { SubcableGeoJson } from '../../entities/subcable/model/types';
import type { ActiveTrack } from '../../entities/vesselTrack/model/types';
import type { ZonesGeoJson } from '../../entities/zone/api/useZones'; import type { ZonesGeoJson } from '../../entities/zone/api/useZones';
import type { MapToggleState } from '../../features/mapToggles/MapToggles'; import type { MapToggleState } from '../../features/mapToggles/MapToggles';
import type { FcLink, FleetCircle, PairLink } from '../../features/legacyDashboard/model/types'; import type { FcLink, FleetCircle, PairLink } from '../../features/legacyDashboard/model/types';
@ -15,6 +16,13 @@ export type Map3DSettings = {
export type BaseMapId = 'enhanced' | 'legacy'; export type BaseMapId = 'enhanced' | 'legacy';
export type MapProjectionId = 'mercator' | 'globe'; export type MapProjectionId = 'mercator' | 'globe';
export interface MapViewState {
center: [number, number]; // [lon, lat]
zoom: number;
bearing: number;
pitch: number;
}
export interface Map3DProps { export interface Map3DProps {
targets: AisTarget[]; targets: AisTarget[];
zones: ZonesGeoJson | null; zones: ZonesGeoJson | null;
@ -52,6 +60,15 @@ export interface Map3DProps {
onHoverCable?: (cableId: string | null) => void; onHoverCable?: (cableId: string | null) => void;
onClickCable?: (cableId: string | null) => void; onClickCable?: (cableId: string | null) => void;
mapStyleSettings?: MapStyleSettings; mapStyleSettings?: MapStyleSettings;
onMapReady?: (map: import('maplibre-gl').Map) => void;
initialView?: MapViewState | null;
onViewStateChange?: (view: MapViewState) => void;
onGlobeShipsReady?: (ready: boolean) => void;
activeTrack?: ActiveTrack | null;
trackContextMenu?: { x: number; y: number; mmsi: number; vesselName: string } | null;
onRequestTrack?: (mmsi: number, minutes: number) => void;
onCloseTrackMenu?: () => void;
onOpenTrackMenu?: (info: { x: number; y: number; mmsi: number; vesselName: string }) => void;
} }
export type DashSeg = { export type DashSeg = {

파일 보기

@ -11,97 +11,161 @@ export function SubcableInfoPanel({ detail, color, onClose }: Props) {
const countries = [...new Set(detail.landing_points.map((lp) => lp.country).filter(Boolean))]; const countries = [...new Set(detail.landing_points.map((lp) => lp.country).filter(Boolean))];
return ( return (
<div className="map-info" style={{ maxWidth: 340 }}> <div className="map-info" style={{ width: 320 }}>
<button className="close-btn" onClick={onClose} aria-label="close"> <button className="close-btn" onClick={onClose} aria-label="close">
</button> </button>
<div style={{ marginBottom: 8 }}> {/* ── Header ── */}
<div style={{ marginBottom: 10, paddingRight: 20 }}>
<div style={{ display: 'flex', alignItems: 'center', gap: 8 }}> <div style={{ display: 'flex', alignItems: 'center', gap: 8 }}>
{color && ( {color && (
<div <div
style={{ style={{
width: 12, width: 14,
height: 12, height: 14,
borderRadius: 3, borderRadius: 3,
backgroundColor: color, backgroundColor: color,
flexShrink: 0, flexShrink: 0,
border: '1px solid rgba(255,255,255,0.15)',
}} }}
/> />
)} )}
<div style={{ fontSize: 16, fontWeight: 900, color: 'var(--accent)' }}>{detail.name}</div> <div style={{ fontSize: 15, fontWeight: 800, color: 'var(--accent)', lineHeight: 1.3 }}>
</div> {detail.name}
<div style={{ fontSize: 10, color: 'var(--muted)', marginTop: 2 }}>
Submarine Cable{detail.is_planned ? ' (Planned)' : ''}
</div> </div>
</div> </div>
{detail.is_planned && (
<div className="ir"> <span
<span className="il"></span> style={{
<span className="iv">{detail.length || '-'}</span> display: 'inline-block',
</div> marginTop: 4,
<div className="ir"> fontSize: 9,
<span className="il"></span> fontWeight: 700,
<span className="iv">{detail.rfs || '-'}</span> color: '#F59E0B',
</div> background: 'rgba(245,158,11,0.12)',
{detail.owners && ( padding: '1px 6px',
<div className="ir" style={{ alignItems: 'flex-start' }}> borderRadius: 3,
<span className="il"></span> letterSpacing: 0.5,
<span className="iv" style={{ wordBreak: 'break-word' }}> textTransform: 'uppercase',
{detail.owners} }}
>
Planned
</span> </span>
</div>
)} )}
{detail.suppliers && (
<div className="ir">
<span className="il"></span>
<span className="iv">{detail.suppliers}</span>
</div> </div>
)}
{/* ── Info rows ── */}
<div style={{ display: 'flex', flexDirection: 'column', gap: 1 }}>
<InfoRow label="길이" value={detail.length} />
<InfoRow label="개통" value={detail.rfs} />
{detail.owners && <InfoRow label="운영사" value={detail.owners} wrap />}
{detail.suppliers && <InfoRow label="공급사" value={detail.suppliers} wrap />}
</div>
{/* ── Landing Points ── */}
{landingCount > 0 && ( {landingCount > 0 && (
<div style={{ marginTop: 8 }}> <div style={{ marginTop: 10 }}>
<div style={{ fontSize: 10, fontWeight: 700, color: 'var(--muted)', marginBottom: 4 }}> <div
Landing Points ({landingCount}) · {countries.length} countries style={{
fontSize: 10,
fontWeight: 700,
color: 'var(--muted)',
marginBottom: 6,
display: 'flex',
justifyContent: 'space-between',
}}
>
<span>Landing Points</span>
<span style={{ fontWeight: 400 }}>
{landingCount} · {countries.length}
</span>
</div> </div>
<div <div
style={{ style={{
maxHeight: 140, maxHeight: 160,
overflowY: 'auto', overflowY: 'auto',
fontSize: 10, display: 'flex',
lineHeight: 1.6, flexDirection: 'column',
color: 'var(--text)', gap: 2,
}} }}
> >
{detail.landing_points.map((lp) => ( {detail.landing_points.map((lp) => (
<div key={lp.id}> <div
<span style={{ color: 'var(--muted)' }}>{lp.country}</span>{' '} key={lp.id}
<b>{lp.name}</b> style={{
{lp.is_tbd && <span style={{ color: '#F59E0B', marginLeft: 4 }}>TBD</span>} display: 'flex',
alignItems: 'baseline',
gap: 6,
fontSize: 10,
lineHeight: 1.5,
padding: '1px 0',
}}
>
<span
style={{
color: 'var(--muted)',
fontSize: 9,
flexShrink: 0,
minWidth: 28,
}}
>
{lp.country}
</span>
<span style={{ fontWeight: 600, color: 'var(--text)' }}>{lp.name}</span>
{lp.is_tbd && (
<span style={{ color: '#F59E0B', fontSize: 8, fontWeight: 700, flexShrink: 0 }}>
TBD
</span>
)}
</div> </div>
))} ))}
</div> </div>
</div> </div>
)} )}
{/* ── Notes ── */}
{detail.notes && ( {detail.notes && (
<div style={{ marginTop: 8, fontSize: 10, color: 'var(--muted)', fontStyle: 'italic' }}> <div
style={{
marginTop: 10,
fontSize: 10,
color: 'var(--muted)',
fontStyle: 'italic',
lineHeight: 1.5,
}}
>
{detail.notes} {detail.notes}
</div> </div>
)} )}
{/* ── Link ── */}
{detail.url && ( {detail.url && (
<div style={{ marginTop: 8 }}> <div style={{ marginTop: 10 }}>
<a <a
href={detail.url} href={detail.url}
target="_blank" target="_blank"
rel="noopener noreferrer" rel="noopener noreferrer"
style={{ fontSize: 10, color: 'var(--accent)' }} style={{ fontSize: 10, color: 'var(--accent)', textDecoration: 'none' }}
> >
Official website
</a> </a>
</div> </div>
)} )}
</div> </div>
); );
} }
function InfoRow({ label, value, wrap }: { label: string; value: string | null; wrap?: boolean }) {
return (
<div className="ir" style={wrap ? { alignItems: 'flex-start' } : undefined}>
<span className="il">{label}</span>
<span
className="iv"
style={wrap ? { textAlign: 'right', wordBreak: 'break-word', maxWidth: '65%' } : undefined}
>
{value || '-'}
</span>
</div>
);
}

파일 보기

@ -9,9 +9,11 @@ type Props = {
clock: string; clock: string;
adminMode?: boolean; adminMode?: boolean;
onLogoClick?: () => void; onLogoClick?: () => void;
userName?: string;
onLogout?: () => void;
}; };
export function Topbar({ total, fishing, transit, pairLinks, alarms, pollingStatus, lastFetchMinutes, clock, adminMode, onLogoClick }: Props) { export function Topbar({ total, fishing, transit, pairLinks, alarms, pollingStatus, lastFetchMinutes, clock, adminMode, onLogoClick, userName, onLogout }: Props) {
const statusColor = const statusColor =
pollingStatus === "ready" ? "#22C55E" : pollingStatus === "loading" ? "#F59E0B" : pollingStatus === "error" ? "#EF4444" : "var(--muted)"; pollingStatus === "ready" ? "#22C55E" : pollingStatus === "loading" ? "#F59E0B" : pollingStatus === "error" ? "#EF4444" : "var(--muted)";
return ( return (
@ -47,6 +49,16 @@ export function Topbar({ total, fishing, transit, pairLinks, alarms, pollingStat
</div> </div>
</div> </div>
<div className="time">{clock}</div> <div className="time">{clock}</div>
{userName && (
<div className="topbar-user">
<span className="topbar-user__name">{userName}</span>
{onLogout && (
<button className="topbar-user__logout" onClick={onLogout}>
</button>
)}
</div>
)}
</div> </div>
); );
} }

파일 보기

@ -0,0 +1,307 @@
import { useCallback, useEffect, useMemo, useRef, useState, type PointerEvent as ReactPointerEvent } from 'react';
import { useTrackPlaybackStore, TRACK_PLAYBACK_SPEED_OPTIONS } from '../../features/trackReplay/stores/trackPlaybackStore';
import { useTrackQueryStore } from '../../features/trackReplay/stores/trackQueryStore';
function formatDateTime(ms: number): string {
if (!Number.isFinite(ms) || ms <= 0) return '--';
const date = new Date(ms);
const pad = (value: number) => String(value).padStart(2, '0');
return `${date.getFullYear()}-${pad(date.getMonth() + 1)}-${pad(date.getDate())} ${pad(date.getHours())}:${pad(
date.getMinutes(),
)}:${pad(date.getSeconds())}`;
}
export function GlobalTrackReplayPanel() {
const PANEL_WIDTH = 420;
const PANEL_MARGIN = 12;
const PANEL_DEFAULT_TOP = 16;
const PANEL_RIGHT_RESERVED = 520;
const panelRef = useRef<HTMLDivElement | null>(null);
const dragRef = useRef<{ pointerId: number; startX: number; startY: number; originX: number; originY: number } | null>(
null,
);
const [isDragging, setIsDragging] = useState(false);
const clampPosition = useCallback(
(x: number, y: number) => {
if (typeof window === 'undefined') return { x, y };
const viewportWidth = window.innerWidth;
const viewportHeight = window.innerHeight;
const panelHeight = panelRef.current?.offsetHeight ?? 360;
return {
x: Math.min(Math.max(PANEL_MARGIN, x), Math.max(PANEL_MARGIN, viewportWidth - PANEL_WIDTH - PANEL_MARGIN)),
y: Math.min(Math.max(PANEL_MARGIN, y), Math.max(PANEL_MARGIN, viewportHeight - panelHeight - PANEL_MARGIN)),
};
},
[PANEL_MARGIN, PANEL_WIDTH],
);
const [position, setPosition] = useState(() => {
if (typeof window === 'undefined') {
return { x: PANEL_MARGIN, y: PANEL_DEFAULT_TOP };
}
return {
x: Math.max(PANEL_MARGIN, window.innerWidth - PANEL_WIDTH - PANEL_RIGHT_RESERVED),
y: PANEL_DEFAULT_TOP,
};
});
const tracks = useTrackQueryStore((state) => state.tracks);
const isLoading = useTrackQueryStore((state) => state.isLoading);
const error = useTrackQueryStore((state) => state.error);
const showPoints = useTrackQueryStore((state) => state.showPoints);
const showVirtualShip = useTrackQueryStore((state) => state.showVirtualShip);
const showLabels = useTrackQueryStore((state) => state.showLabels);
const showTrail = useTrackQueryStore((state) => state.showTrail);
const hideLiveShips = useTrackQueryStore((state) => state.hideLiveShips);
const setShowPoints = useTrackQueryStore((state) => state.setShowPoints);
const setShowVirtualShip = useTrackQueryStore((state) => state.setShowVirtualShip);
const setShowLabels = useTrackQueryStore((state) => state.setShowLabels);
const setShowTrail = useTrackQueryStore((state) => state.setShowTrail);
const setHideLiveShips = useTrackQueryStore((state) => state.setHideLiveShips);
const closeTrackQuery = useTrackQueryStore((state) => state.closeQuery);
const isPlaying = useTrackPlaybackStore((state) => state.isPlaying);
const currentTime = useTrackPlaybackStore((state) => state.currentTime);
const startTime = useTrackPlaybackStore((state) => state.startTime);
const endTime = useTrackPlaybackStore((state) => state.endTime);
const playbackSpeed = useTrackPlaybackStore((state) => state.playbackSpeed);
const loop = useTrackPlaybackStore((state) => state.loop);
const play = useTrackPlaybackStore((state) => state.play);
const pause = useTrackPlaybackStore((state) => state.pause);
const stop = useTrackPlaybackStore((state) => state.stop);
const setCurrentTime = useTrackPlaybackStore((state) => state.setCurrentTime);
const setPlaybackSpeed = useTrackPlaybackStore((state) => state.setPlaybackSpeed);
const toggleLoop = useTrackPlaybackStore((state) => state.toggleLoop);
const progress = useMemo(() => {
if (endTime <= startTime) return 0;
return ((currentTime - startTime) / (endTime - startTime)) * 100;
}, [startTime, endTime, currentTime]);
const isVisible = isLoading || tracks.length > 0 || !!error;
useEffect(() => {
if (!isVisible) return;
if (typeof window === 'undefined') return;
const onResize = () => {
setPosition((prev) => clampPosition(prev.x, prev.y));
};
window.addEventListener('resize', onResize);
return () => window.removeEventListener('resize', onResize);
}, [clampPosition, isVisible]);
useEffect(() => {
if (!isVisible) return;
const onPointerMove = (event: PointerEvent) => {
const drag = dragRef.current;
if (!drag || drag.pointerId !== event.pointerId) return;
setPosition(() => {
const nextX = drag.originX + (event.clientX - drag.startX);
const nextY = drag.originY + (event.clientY - drag.startY);
return clampPosition(nextX, nextY);
});
};
const stopDrag = (event: PointerEvent) => {
const drag = dragRef.current;
if (!drag || drag.pointerId !== event.pointerId) return;
dragRef.current = null;
setIsDragging(false);
};
window.addEventListener('pointermove', onPointerMove);
window.addEventListener('pointerup', stopDrag);
window.addEventListener('pointercancel', stopDrag);
return () => {
window.removeEventListener('pointermove', onPointerMove);
window.removeEventListener('pointerup', stopDrag);
window.removeEventListener('pointercancel', stopDrag);
};
}, [clampPosition, isVisible]);
const handleHeaderPointerDown = useCallback(
(event: ReactPointerEvent<HTMLDivElement>) => {
if (event.button !== 0) return;
dragRef.current = {
pointerId: event.pointerId,
startX: event.clientX,
startY: event.clientY,
originX: position.x,
originY: position.y,
};
setIsDragging(true);
try {
event.currentTarget.setPointerCapture(event.pointerId);
} catch {
// ignore
}
},
[position.x, position.y],
);
if (!isVisible) return null;
return (
<div
ref={panelRef}
style={{
position: 'absolute',
left: position.x,
top: position.y,
width: PANEL_WIDTH,
background: 'rgba(15,23,42,0.94)',
border: '1px solid rgba(148,163,184,0.35)',
borderRadius: 12,
padding: 12,
color: '#e2e8f0',
zIndex: 40,
backdropFilter: 'blur(8px)',
boxShadow: '0 8px 24px rgba(2,6,23,0.45)',
}}
>
<div
onPointerDown={handleHeaderPointerDown}
style={{
display: 'flex',
alignItems: 'center',
justifyContent: 'space-between',
marginBottom: 8,
cursor: isDragging ? 'grabbing' : 'grab',
userSelect: 'none',
touchAction: 'none',
}}
>
<strong style={{ fontSize: 13 }}>Track Replay</strong>
<button
type="button"
onClick={() => closeTrackQuery()}
onPointerDown={(event) => event.stopPropagation()}
style={{
fontSize: 11,
padding: '4px 8px',
borderRadius: 6,
border: '1px solid rgba(148,163,184,0.5)',
background: 'rgba(30,41,59,0.7)',
color: '#e2e8f0',
cursor: 'pointer',
}}
>
</button>
</div>
{error ? (
<div style={{ marginBottom: 8, color: '#fca5a5', fontSize: 12 }}>{error}</div>
) : null}
{isLoading ? <div style={{ marginBottom: 8, fontSize: 12 }}> ...</div> : null}
<div style={{ fontSize: 11, color: '#93c5fd', marginBottom: 8 }}>
{tracks.length} · {formatDateTime(startTime)} ~ {formatDateTime(endTime)}
</div>
<div style={{ display: 'flex', gap: 8, marginBottom: 10 }}>
<button
type="button"
onClick={() => (isPlaying ? pause() : play())}
disabled={tracks.length === 0}
style={{
padding: '6px 10px',
borderRadius: 6,
border: '1px solid rgba(148,163,184,0.45)',
background: 'rgba(30,41,59,0.8)',
color: '#e2e8f0',
cursor: 'pointer',
}}
>
{isPlaying ? '일시정지' : '재생'}
</button>
<button
type="button"
onClick={() => stop()}
disabled={tracks.length === 0}
style={{
padding: '6px 10px',
borderRadius: 6,
border: '1px solid rgba(148,163,184,0.45)',
background: 'rgba(30,41,59,0.8)',
color: '#e2e8f0',
cursor: 'pointer',
}}
>
</button>
<label style={{ fontSize: 12, display: 'flex', alignItems: 'center', gap: 4 }}>
<select
value={playbackSpeed}
onChange={(event) => setPlaybackSpeed(Number(event.target.value))}
style={{
background: 'rgba(30,41,59,0.85)',
border: '1px solid rgba(148,163,184,0.45)',
borderRadius: 6,
color: '#e2e8f0',
fontSize: 12,
padding: '4px 6px',
}}
>
{TRACK_PLAYBACK_SPEED_OPTIONS.map((speed) => (
<option key={speed} value={speed}>
{speed}x
</option>
))}
</select>
</label>
</div>
<div style={{ marginBottom: 10 }}>
<input
type="range"
min={startTime}
max={endTime || startTime + 1}
value={currentTime}
onChange={(event) => setCurrentTime(Number(event.target.value))}
style={{ width: '100%' }}
disabled={tracks.length === 0 || endTime <= startTime}
/>
<div style={{ display: 'flex', justifyContent: 'space-between', fontSize: 11, color: '#94a3b8' }}>
<span>{formatDateTime(currentTime)}</span>
<span>{Math.max(0, Math.min(100, progress)).toFixed(1)}%</span>
</div>
</div>
<div style={{ display: 'grid', gridTemplateColumns: 'repeat(3, minmax(0, 1fr))', gap: 6, fontSize: 12 }}>
<label>
<input type="checkbox" checked={showPoints} onChange={(event) => setShowPoints(event.target.checked)} />
</label>
<label>
<input
type="checkbox"
checked={showVirtualShip}
onChange={(event) => setShowVirtualShip(event.target.checked)}
/>{' '}
</label>
<label>
<input type="checkbox" checked={showLabels} onChange={(event) => setShowLabels(event.target.checked)} />
</label>
<label>
<input type="checkbox" checked={showTrail} onChange={(event) => setShowTrail(event.target.checked)} />
</label>
<label>
<input
type="checkbox"
checked={hideLiveShips}
onChange={(event) => setHideLiveShips(event.target.checked)}
/>{' '}
</label>
<label>
<input type="checkbox" checked={loop} onChange={() => toggleLoop()} />
</label>
</div>
</div>
);
}

파일 보기

@ -0,0 +1,316 @@
import { useMemo, useRef, useState } from 'react';
import {
WEATHER_LAYERS,
LEGEND_META,
SPEED_OPTIONS,
type WeatherLayerId,
type WeatherOverlayState,
type WeatherOverlayActions,
} from '../../features/weatherOverlay/useWeatherOverlay';
type Props = WeatherOverlayState & WeatherOverlayActions;
/** 절대 시간 표기 (MM. DD. HH:mm) */
function fmtBase(d: Date | null): string {
if (!d) return '-';
const mm = String(d.getMonth() + 1).padStart(2, '0');
const dd = String(d.getDate()).padStart(2, '0');
const hh = String(d.getHours()).padStart(2, '0');
const mi = String(d.getMinutes()).padStart(2, '0');
return `${mm}. ${dd}. ${hh}:${mi}`;
}
/** epoch 초 → Date */
function secToDate(sec: number): Date {
return new Date(sec * 1000);
}
/** 기준시간 대비 오프셋 표기 (+HH:MM) */
function fmtOffset(base: Date | null, target: Date | null): string {
if (!base || !target) return '-';
const diffMs = target.getTime() - base.getTime();
const totalMin = Math.round(diffMs / 60_000);
const h = Math.floor(Math.abs(totalMin) / 60);
const m = Math.abs(totalMin) % 60;
const sign = totalMin >= 0 ? '+' : '-';
return `${sign}${h}:${String(m).padStart(2, '0')}`;
}
/** ColorRamp → CSS linear-gradient */
function rampToGradient(layerId: WeatherLayerId): string {
const { colorRamp } = LEGEND_META[layerId];
const stops = colorRamp.getRawColorStops();
if (stops.length === 0) return 'transparent';
const { min, max } = colorRamp.getBounds();
const range = max - min || 1;
const css = stops.map((s) => {
const [r, g, b, a] = s.color;
const pct = ((s.value - min) / range) * 100;
return `rgba(${r},${g},${b},${(a ?? 255) / 255}) ${pct.toFixed(1)}%`;
});
return `linear-gradient(to right, ${css.join(', ')})`;
}
/** 범례 눈금 생성 */
function getLegendTicks(layerId: WeatherLayerId): string[] {
const { colorRamp, unit } = LEGEND_META[layerId];
const { min, max } = colorRamp.getBounds();
const count = 5;
const step = (max - min) / count;
const ticks: string[] = [];
for (let i = 0; i <= count; i++) {
const v = min + step * i;
ticks.push(Number.isInteger(v) ? String(v) : v.toFixed(1));
}
ticks[ticks.length - 1] += unit;
return ticks;
}
/** steps 배열에서 value 에 가장 가까운 step epoch 초 반환 */
function snapToStep(steps: number[], value: number): number {
if (steps.length === 0) return value;
let best = steps[0];
let bestDist = Math.abs(value - best);
for (let i = 1; i < steps.length; i++) {
const dist = Math.abs(value - steps[i]);
if (dist < bestDist) {
best = steps[i];
bestDist = dist;
}
}
return best;
}
export function WeatherOverlayPanel(props: Props) {
const {
enabled,
activeLayerId,
opacity,
isPlaying,
animationSpeed,
currentTime,
startTime,
endTime,
steps,
isReady,
toggleLayer,
setOpacity,
play,
pause,
setSpeed,
seekTo,
} = props;
const [open, setOpen] = useState(false);
const activeCount = Object.values(enabled).filter(Boolean).length;
/** 드래그 중 현재 step index */
const [draggingIdx, setDraggingIdx] = useState<number | null>(null);
/** 마지막으로 seek 요청한 step index (중복 방지) */
const lastSeekedRef = useRef<number | null>(null);
const legendGradient = useMemo(
() => (activeLayerId ? rampToGradient(activeLayerId) : null),
[activeLayerId],
);
const legendTicks = useMemo(
() => (activeLayerId ? getLegendTicks(activeLayerId) : []),
[activeLayerId],
);
// 현재 시간을 step index로 변환 (재생 중 폴링 값 반영)
const currentStepIdx =
draggingIdx != null
? draggingIdx
: steps.length > 0 && currentTime
? (() => {
const curSec = currentTime.getTime() / 1000;
const snapped = snapToStep(steps, curSec);
return steps.indexOf(snapped);
})()
: 0;
/** 드래그 중: step 경계를 넘을 때마다 실시간 seek */
const handleSliderInput = (e: React.ChangeEvent<HTMLInputElement>) => {
const idx = Number(e.target.value);
setDraggingIdx(idx);
// step이 바뀌었을 때만 seek (동일 step 반복 방지)
if (lastSeekedRef.current !== idx && steps[idx] != null) {
lastSeekedRef.current = idx;
seekTo(steps[idx]);
}
};
/** 드래그 끝: 정리 */
const handleSliderCommit = () => {
lastSeekedRef.current = null;
setDraggingIdx(null);
};
const legendInfo = activeLayerId ? LEGEND_META[activeLayerId] : null;
const showPanel = open;
const showLegend = !!activeLayerId && !!legendInfo && !!legendGradient;
return (
<>
<button
className={`wo-gear${open ? ' open' : ''}${activeCount > 0 ? ' active' : ''}`}
onClick={() => {
setOpen((prev) => {
if (prev && activeCount > 0) {
toggleLayer(activeLayerId!);
pause();
}
return !prev;
});
}}
title="기상 타일 오버레이"
type="button"
>
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round">
<path d="M12 2v4M12 18v4M4.93 4.93l2.83 2.83M16.24 16.24l2.83 2.83M2 12h4M18 12h4M4.93 19.07l2.83-2.83M16.24 7.76l2.83-2.83" />
<circle cx="12" cy="12" r="3" />
</svg>
{activeCount > 0 && <span className="wo-gear-badge">{activeCount}</span>}
</button>
{(showPanel || showLegend) && (
<div className="wo-stack">
{showPanel && (
<div className="wo-panel">
<div className="wo-header">
<span className="wo-title"> </span>
{!isReady && activeCount > 0 && <span className="wo-loading">...</span>}
</div>
<div className="wo-layers">
{WEATHER_LAYERS.map((meta) => (
<button
key={meta.id}
className={`wo-layer-btn${enabled[meta.id] ? ' on' : ''}`}
onClick={() => toggleLayer(meta.id as WeatherLayerId)}
title={meta.label}
type="button"
>
<span className="wo-layer-icon">{meta.icon}</span>
<span className="wo-layer-name">{meta.label}</span>
</button>
))}
</div>
<div className="wo-section">
<div className="wo-label">
<span className="wo-val">{Math.round(opacity * 100)}%</span>
</div>
<input
type="range"
className="wo-slider"
min={0}
max={100}
value={Math.round(opacity * 100)}
onChange={(e) => setOpacity(Number(e.target.value) / 100)}
/>
</div>
{activeCount > 0 && steps.length > 0 && (
<div className="wo-section wo-timeline">
<div className="wo-label">
<span className="wo-val">{fmtBase(startTime)}</span>
{' · '}
<span className="wo-val wo-offset">{fmtOffset(startTime, currentTime)}</span>
</div>
{/* step 기반 슬라이더 */}
<div className="wo-step-slider-wrap">
<input
type="range"
className="wo-slider wo-time-slider"
min={0}
max={steps.length - 1}
step={1}
value={currentStepIdx}
onChange={handleSliderInput}
onMouseUp={handleSliderCommit}
onTouchEnd={handleSliderCommit}
disabled={!isReady}
/>
{/* 눈금 표시 */}
<div className="wo-step-ticks">
{steps.map((sec, i) => {
const pct = steps.length > 1 ? (i / (steps.length - 1)) * 100 : 0;
const d = secToDate(sec);
const isDay = d.getHours() === 0 && d.getMinutes() === 0;
return (
<span
key={sec}
className={`wo-step-tick${isDay ? ' day' : ''}`}
style={{ left: `${pct}%` }}
/>
);
})}
</div>
</div>
<div className="wo-time-range">
<span>{fmtBase(startTime)}</span>
<span>
{draggingIdx != null && steps[draggingIdx]
? fmtBase(secToDate(steps[draggingIdx]))
: fmtBase(currentTime)}
</span>
<span>{fmtBase(endTime)}</span>
</div>
<div className="wo-playback">
<button
className="wo-play-btn"
type="button"
onClick={isPlaying ? pause : play}
disabled={!isReady}
title={isPlaying ? '일시정지' : '재생'}
>
{isPlaying ? '⏸' : '▶'}
</button>
<div className="wo-speed-btns">
{SPEED_OPTIONS.map((opt) => (
<button
key={opt.value}
className={`wo-speed-btn${animationSpeed === opt.value ? ' on' : ''}`}
onClick={() => setSpeed(opt.value)}
type="button"
title={`${opt.label} 속도`}
>
{opt.label}
</button>
))}
</div>
</div>
</div>
)}
<div className="wo-hint">
MapTiler Weather SDK
</div>
</div>
)}
{showLegend && (
<div className="wo-legend">
<div className="wo-legend-header">
{legendInfo!.label} ({legendInfo!.unit})
</div>
<div
className="wo-legend-bar"
style={{ background: legendGradient! }}
/>
<div className="wo-legend-ticks">
{legendTicks.map((t, i) => (
<span key={i}>{t}</span>
))}
</div>
</div>
)}
</div>
)}
</>
);
}

파일 보기

@ -0,0 +1,118 @@
import { useState } from 'react';
import type { WeatherSnapshot } from '../../entities/weather/model/types';
import {
getWindDirectionLabel,
getWindArrow,
getWaveSeverity,
getWeatherLabel,
} from '../../entities/weather/lib/weatherUtils';
interface WeatherPanelProps {
snapshot: WeatherSnapshot | null;
isLoading: boolean;
error: string | null;
onRefresh: () => void;
}
function fmtTime(ts: number): string {
const d = new Date(ts);
return `${String(d.getHours()).padStart(2, '0')}:${String(d.getMinutes()).padStart(2, '0')}`;
}
function fmtVal(v: number | null, unit: string, decimals = 1): string {
if (v == null) return '-';
return `${v.toFixed(decimals)}${unit}`;
}
export function WeatherPanel({ snapshot, isLoading, error, onRefresh }: WeatherPanelProps) {
const [open, setOpen] = useState(false);
return (
<>
<button
className={`weather-gear${open ? ' open' : ''}`}
onClick={() => setOpen((p) => !p)}
title="해양 기상 현황"
type="button"
>
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round">
<path d="M2 12h2M6 8c0-2.2 1.8-4 4-4 1.5 0 2.8.8 3.5 2h.5c1.7 0 3 1.3 3 3s-1.3 3-3 3H6c-2.2 0-4-1.8-4-4z" />
<path d="M2 20h2M8 16a3 3 0 0 1 3-3h2a3 3 0 0 1 3 3 3 3 0 0 1-3 3H8a3 3 0 0 1 0-6z" />
</svg>
</button>
{open && (
<div className="weather-panel">
<div className="wp-header">
<span className="wp-title"> </span>
{isLoading && <span className="wp-loading">...</span>}
</div>
{error && <div className="wp-error">{error}</div>}
{snapshot && snapshot.points.map((pt) => {
const severity = getWaveSeverity(pt.waveHeight);
const isWarn = severity === 'rough' || severity === 'severe'
|| (pt.windSpeed != null && pt.windSpeed >= 10);
return (
<div
key={pt.label}
className={`wz-card${isWarn ? ' wz-warn' : ''}`}
style={{ borderLeftColor: pt.color }}
>
<div className="wz-name">{pt.label}</div>
<div className="wz-row">
<span className="wz-item">
<span className="wz-icon">~</span>
<span className="wz-value">{fmtVal(pt.waveHeight, 'm')}</span>
</span>
<span className="wz-item">
<span className="wz-icon">{getWindArrow(pt.windDirection)}</span>
<span className="wz-value">
{fmtVal(pt.windSpeed, 'm/s')} {getWindDirectionLabel(pt.windDirection)}
</span>
</span>
</div>
<div className="wz-row">
<span className="wz-item">
<span className="wz-label"></span>
<span className="wz-value">{fmtVal(pt.seaSurfaceTemp, '°C')}</span>
</span>
<span className="wz-item">
<span className="wz-label"></span>
<span className="wz-value">{fmtVal(pt.swellHeight, 'm')}</span>
</span>
{pt.weatherCode != null && (
<span className="wz-item">
<span className="wz-value wz-weather">{getWeatherLabel(pt.weatherCode)}</span>
</span>
)}
</div>
</div>
);
})}
{!snapshot && !isLoading && !error && (
<div className="wp-empty"> </div>
)}
<div className="wp-footer">
{snapshot && (
<span className="wp-time"> {fmtTime(snapshot.fetchedAt)}</span>
)}
<button
className="wp-refresh"
type="button"
onClick={onRefresh}
disabled={isLoading}
title="새로고침"
>
</button>
</div>
</div>
)}
</>
);
}

1274
package-lock.json generated

파일 크기가 너무 크기때문에 변경 상태를 표시하지 않습니다. Load Diff