Compare commits
54 커밋
feature/ma
...
develop
| 작성자 | SHA1 | 날짜 | |
|---|---|---|---|
| 4d1a8a0d1e | |||
| 86a0b2276f | |||
| c09211d5ed | |||
| 6acf2045b2 | |||
| cb6493b8a1 | |||
| c05ec159ce | |||
| 69775c90a2 | |||
| c23264c3ba | |||
| c423b59244 | |||
| fe5ec7100b | |||
| c03dee0ade | |||
| 1fd9f3da82 | |||
| 3a001ca9b6 | |||
| 2095503e50 | |||
| f50c227fd4 | |||
| b022e4bc36 | |||
| d5700ba587 | |||
| 7bec1ae86d | |||
| 99d714582b | |||
| 95d9ea8aef | |||
| 91df90b528 | |||
| 4cf0f20504 | |||
| d88c89403d | |||
| 77e3a531e8 | |||
| 39d9cc9db1 | |||
| 8dc216ae1c | |||
| 16ebf3abca | |||
| bd3b3f9a8c | |||
| cc807bb5f6 | |||
| ca43bbcebb | |||
| 20db4cb0cf | |||
| 79b21c7d44 | |||
| 22099862e6 | |||
| 705cc696f9 | |||
| fd3cedc6c1 | |||
| ec83ce8ea0 | |||
| aeb57e9bb8 | |||
| fb8d65f83f | |||
| 9fd0567ccf | |||
| 3acda7432e | |||
| d2178a6134 | |||
| 1a3dd82eb4 | |||
| 650888adb7 | |||
| 4c257a2883 | |||
| 289f1bebc0 | |||
| f5ef24c02f | |||
| a16ccc9a01 | |||
| fb1334ce45 | |||
| 7eff97afd4 | |||
| ca5560aff2 | |||
| 621a5037c2 | |||
| 3ba6c02ba0 | |||
| 864fc44d0e | |||
| 324c6267f0 |
41
.gitea/workflows/deploy.yml
Normal file
41
.gitea/workflows/deploy.yml
Normal file
@ -0,0 +1,41 @@
|
|||||||
|
name: Build and Deploy Wing
|
||||||
|
|
||||||
|
on:
|
||||||
|
push:
|
||||||
|
branches:
|
||||||
|
- main
|
||||||
|
|
||||||
|
jobs:
|
||||||
|
build-and-deploy:
|
||||||
|
runs-on: ubuntu-latest
|
||||||
|
steps:
|
||||||
|
- name: Checkout
|
||||||
|
uses: actions/checkout@v4
|
||||||
|
|
||||||
|
- name: Setup Node.js
|
||||||
|
uses: actions/setup-node@v4
|
||||||
|
with:
|
||||||
|
node-version: '24'
|
||||||
|
|
||||||
|
- name: Configure npm registry
|
||||||
|
run: |
|
||||||
|
echo "registry=https://nexus.gc-si.dev/repository/npm-public/" > .npmrc
|
||||||
|
echo "//nexus.gc-si.dev/repository/npm-public/:_auth=${{ secrets.NEXUS_NPM_AUTH }}" >> .npmrc
|
||||||
|
|
||||||
|
- name: Install dependencies
|
||||||
|
run: npm ci
|
||||||
|
|
||||||
|
- name: Build web
|
||||||
|
env:
|
||||||
|
VITE_MAPTILER_KEY: ${{ secrets.MAPTILER_KEY }}
|
||||||
|
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
|
||||||
|
|
||||||
|
- name: Deploy to server
|
||||||
|
run: |
|
||||||
|
rm -rf /deploy/wing/*
|
||||||
|
cp -r apps/web/dist/* /deploy/wing/
|
||||||
|
echo "Deployed at $(date '+%Y-%m-%d %H:%M:%S')"
|
||||||
|
ls -la /deploy/wing/
|
||||||
54
CLAUDE.md
54
CLAUDE.md
@ -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:web # 프론트엔드 (Vite)
|
||||||
# 전체 개발 서버
|
npm run dev:api # 백엔드 (Fastify + tsx watch)
|
||||||
npm run dev
|
npm run build # 전체 빌드 (web + api)
|
||||||
|
npm run build:web # 프론트엔드만
|
||||||
# 개별 개발 서버
|
npm run lint # apps/web ESLint
|
||||||
npm run dev:web # 프론트엔드 (Vite)
|
npm run prepare:data # 정적 데이터 준비
|
||||||
npm run dev:api # 백엔드 (Fastify + tsx watch)
|
|
||||||
|
|
||||||
# 빌드
|
|
||||||
npm run build # 전체 빌드 (web + api)
|
|
||||||
npm run build:web # 프론트엔드만
|
|
||||||
npm run build:api # 백엔드만
|
|
||||||
|
|
||||||
# 린트
|
|
||||||
npm run lint # apps/web ESLint
|
|
||||||
|
|
||||||
# 데이터 준비
|
|
||||||
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",
|
||||||
|
|||||||
1
apps/web/public/data/subcables/cable-details.min.json
Normal file
1
apps/web/public/data/subcables/cable-details.min.json
Normal file
File diff suppressed because one or more lines are too long
1
apps/web/public/data/subcables/cable-geo.json
Normal file
1
apps/web/public/data/subcables/cable-geo.json
Normal file
File diff suppressed because one or more lines are too long
@ -1,6 +1,7 @@
|
|||||||
{
|
{
|
||||||
"version": 8,
|
"version": 8,
|
||||||
"name": "CARTO Dark (Legacy)",
|
"name": "CARTO Dark (Legacy)",
|
||||||
|
"glyphs": "https://demotiles.maplibre.org/font/{fontstack}/{range}.pbf",
|
||||||
"sources": {
|
"sources": {
|
||||||
"carto-dark": {
|
"carto-dark": {
|
||||||
"type": "raster",
|
"type": "raster",
|
||||||
|
|||||||
@ -1,6 +1,7 @@
|
|||||||
{
|
{
|
||||||
"version": 8,
|
"version": 8,
|
||||||
"name": "OSM Raster + OpenSeaMap",
|
"name": "OSM Raster + OpenSeaMap",
|
||||||
|
"glyphs": "https://demotiles.maplibre.org/font/{fontstack}/{range}.pbf",
|
||||||
"sources": {
|
"sources": {
|
||||||
"osm": {
|
"osm": {
|
||||||
"type": "raster",
|
"type": "raster",
|
||||||
|
|||||||
62
apps/web/public/sw-weather-cache.js
Normal file
62
apps/web/public/sw-weather-cache.js
Normal file
@ -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 {
|
||||||
@ -921,6 +922,886 @@ body {
|
|||||||
border-radius: 8px;
|
border-radius: 8px;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/* ── Map Settings Panel ────────────────────────────────────────────── */
|
||||||
|
|
||||||
|
.map-settings-gear {
|
||||||
|
position: absolute;
|
||||||
|
top: 100px;
|
||||||
|
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;
|
||||||
|
}
|
||||||
|
|
||||||
|
.map-settings-gear:hover {
|
||||||
|
color: var(--text);
|
||||||
|
border-color: var(--accent);
|
||||||
|
}
|
||||||
|
|
||||||
|
.map-settings-gear.open {
|
||||||
|
color: var(--accent);
|
||||||
|
border-color: var(--accent);
|
||||||
|
}
|
||||||
|
|
||||||
|
.map-settings-panel {
|
||||||
|
position: absolute;
|
||||||
|
top: 10px;
|
||||||
|
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: 12px;
|
||||||
|
width: 240px;
|
||||||
|
max-height: calc(100vh - 80px);
|
||||||
|
overflow-y: auto;
|
||||||
|
}
|
||||||
|
|
||||||
|
.map-settings-panel .ms-title {
|
||||||
|
font-size: 11px;
|
||||||
|
font-weight: 700;
|
||||||
|
color: var(--text);
|
||||||
|
letter-spacing: 1px;
|
||||||
|
margin-bottom: 10px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.map-settings-panel .ms-section {
|
||||||
|
margin-bottom: 10px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.map-settings-panel .ms-label {
|
||||||
|
font-size: 10px;
|
||||||
|
font-weight: 600;
|
||||||
|
color: var(--text);
|
||||||
|
letter-spacing: 0.5px;
|
||||||
|
margin-bottom: 5px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.map-settings-panel .ms-row {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 8px;
|
||||||
|
margin-bottom: 4px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.map-settings-panel .ms-color-input {
|
||||||
|
width: 24px;
|
||||||
|
height: 24px;
|
||||||
|
border: 1px solid var(--border);
|
||||||
|
border-radius: 4px;
|
||||||
|
padding: 0;
|
||||||
|
cursor: pointer;
|
||||||
|
background: transparent;
|
||||||
|
flex-shrink: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.map-settings-panel .ms-color-input::-webkit-color-swatch-wrapper {
|
||||||
|
padding: 1px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.map-settings-panel .ms-color-input::-webkit-color-swatch {
|
||||||
|
border: none;
|
||||||
|
border-radius: 2px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.map-settings-panel .ms-hex {
|
||||||
|
font-size: 9px;
|
||||||
|
color: #94a3b8;
|
||||||
|
font-family: monospace;
|
||||||
|
}
|
||||||
|
|
||||||
|
.map-settings-panel .ms-depth-label {
|
||||||
|
font-size: 10px;
|
||||||
|
color: var(--text);
|
||||||
|
min-width: 48px;
|
||||||
|
text-align: right;
|
||||||
|
}
|
||||||
|
|
||||||
|
.map-settings-panel select {
|
||||||
|
font-size: 10px;
|
||||||
|
padding: 4px 8px;
|
||||||
|
border-radius: 4px;
|
||||||
|
border: 1px solid var(--border);
|
||||||
|
background: var(--card);
|
||||||
|
color: var(--text);
|
||||||
|
cursor: pointer;
|
||||||
|
outline: none;
|
||||||
|
width: 100%;
|
||||||
|
}
|
||||||
|
|
||||||
|
.map-settings-panel select:focus {
|
||||||
|
border-color: var(--accent);
|
||||||
|
}
|
||||||
|
|
||||||
|
.map-settings-panel .ms-reset {
|
||||||
|
width: 100%;
|
||||||
|
font-size: 9px;
|
||||||
|
padding: 5px 8px;
|
||||||
|
border-radius: 4px;
|
||||||
|
border: 1px solid var(--border);
|
||||||
|
background: var(--card);
|
||||||
|
color: var(--muted);
|
||||||
|
cursor: pointer;
|
||||||
|
transition: all 0.15s;
|
||||||
|
margin-top: 4px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.map-settings-panel .ms-reset:hover {
|
||||||
|
color: var(--text);
|
||||||
|
border-color: var(--accent);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ── Depth Legend ──────────────────────────────────────────────────── */
|
||||||
|
|
||||||
|
.depth-legend {
|
||||||
|
position: absolute;
|
||||||
|
bottom: 44px;
|
||||||
|
left: 10px;
|
||||||
|
z-index: 800;
|
||||||
|
background: rgba(15, 23, 42, 0.92);
|
||||||
|
backdrop-filter: blur(8px);
|
||||||
|
border: 1px solid var(--border);
|
||||||
|
border-radius: 8px;
|
||||||
|
padding: 8px;
|
||||||
|
display: flex;
|
||||||
|
gap: 6px;
|
||||||
|
align-items: stretch;
|
||||||
|
}
|
||||||
|
|
||||||
|
.depth-legend__bar {
|
||||||
|
width: 14px;
|
||||||
|
border-radius: 3px;
|
||||||
|
min-height: 120px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.depth-legend__ticks {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
justify-content: space-between;
|
||||||
|
font-size: 8px;
|
||||||
|
color: var(--muted);
|
||||||
|
font-family: monospace;
|
||||||
|
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;
|
||||||
|
|||||||
32
apps/web/src/entities/aisTarget/api/searchChnprmship.ts
Normal file
32
apps/web/src/entities/aisTarget/api/searchChnprmship.ts
Normal file
@ -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;
|
||||||
|
}
|
||||||
52
apps/web/src/entities/subcable/api/useSubcables.ts
Normal file
52
apps/web/src/entities/subcable/api/useSubcables.ts
Normal file
@ -0,0 +1,52 @@
|
|||||||
|
import { useEffect, useState } from 'react';
|
||||||
|
import type { SubcableGeoJson, SubcableDetailsIndex, SubcableDetail } from '../model/types';
|
||||||
|
|
||||||
|
interface SubcableData {
|
||||||
|
geo: SubcableGeoJson;
|
||||||
|
details: Map<string, SubcableDetail>;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function useSubcables(
|
||||||
|
geoUrl = '/data/subcables/cable-geo.json',
|
||||||
|
detailsUrl = '/data/subcables/cable-details.min.json',
|
||||||
|
) {
|
||||||
|
const [data, setData] = useState<SubcableData | null>(null);
|
||||||
|
const [error, setError] = useState<string | null>(null);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
let cancelled = false;
|
||||||
|
|
||||||
|
async function run() {
|
||||||
|
try {
|
||||||
|
setError(null);
|
||||||
|
const [geoRes, detailsRes] = await Promise.all([
|
||||||
|
fetch(geoUrl),
|
||||||
|
fetch(detailsUrl),
|
||||||
|
]);
|
||||||
|
if (!geoRes.ok) throw new Error(`Failed to load subcable geo: ${geoRes.status}`);
|
||||||
|
if (!detailsRes.ok) throw new Error(`Failed to load subcable details: ${detailsRes.status}`);
|
||||||
|
|
||||||
|
const geo = (await geoRes.json()) as SubcableGeoJson;
|
||||||
|
const detailsJson = (await detailsRes.json()) as SubcableDetailsIndex;
|
||||||
|
if (cancelled) return;
|
||||||
|
|
||||||
|
const details = new Map<string, SubcableDetail>();
|
||||||
|
for (const [id, detail] of Object.entries(detailsJson.by_id)) {
|
||||||
|
details.set(id, detail);
|
||||||
|
}
|
||||||
|
|
||||||
|
setData({ geo, details });
|
||||||
|
} catch (e) {
|
||||||
|
if (cancelled) return;
|
||||||
|
setError(e instanceof Error ? e.message : String(e));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
void run();
|
||||||
|
return () => {
|
||||||
|
cancelled = true;
|
||||||
|
};
|
||||||
|
}, [geoUrl, detailsUrl]);
|
||||||
|
|
||||||
|
return { data, error };
|
||||||
|
}
|
||||||
39
apps/web/src/entities/subcable/model/types.ts
Normal file
39
apps/web/src/entities/subcable/model/types.ts
Normal file
@ -0,0 +1,39 @@
|
|||||||
|
export interface SubcableFeatureProperties {
|
||||||
|
id: string;
|
||||||
|
name: string;
|
||||||
|
color: string;
|
||||||
|
feature_id: string;
|
||||||
|
coordinates: [number, number];
|
||||||
|
}
|
||||||
|
|
||||||
|
export type SubcableGeoJson = GeoJSON.FeatureCollection<
|
||||||
|
GeoJSON.MultiLineString,
|
||||||
|
SubcableFeatureProperties
|
||||||
|
>;
|
||||||
|
|
||||||
|
export interface SubcableLandingPoint {
|
||||||
|
id: string;
|
||||||
|
name: string;
|
||||||
|
country: string;
|
||||||
|
is_tbd: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface SubcableDetail {
|
||||||
|
id: string;
|
||||||
|
name: string;
|
||||||
|
length: string | null;
|
||||||
|
rfs: string | null;
|
||||||
|
rfs_year: number | null;
|
||||||
|
is_planned: boolean;
|
||||||
|
owners: string | null;
|
||||||
|
suppliers: string | null;
|
||||||
|
landing_points: SubcableLandingPoint[];
|
||||||
|
notes: string | null;
|
||||||
|
url: string | null;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface SubcableDetailsIndex {
|
||||||
|
version: number;
|
||||||
|
generated_at: string;
|
||||||
|
by_id: Record<string, SubcableDetail>;
|
||||||
|
}
|
||||||
32
apps/web/src/entities/vesselTrack/api/fetchTrack.ts
Normal file
32
apps/web/src/entities/vesselTrack/api/fetchTrack.ts
Normal file
@ -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);
|
||||||
|
}
|
||||||
|
}
|
||||||
115
apps/web/src/entities/vesselTrack/lib/buildTrackGeoJson.ts
Normal file
115
apps/web/src/entities/vesselTrack/lib/buildTrackGeoJson.ts
Normal file
@ -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 };
|
||||||
|
}
|
||||||
39
apps/web/src/entities/vesselTrack/model/types.ts
Normal file
39
apps/web/src/entities/vesselTrack/model/types.ts
Normal file
@ -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];
|
||||||
|
}
|
||||||
107
apps/web/src/entities/weather/api/fetchWeather.ts
Normal file
107
apps/web/src/entities/weather/api/fetchWeather.ts
Normal file
@ -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);
|
||||||
|
}
|
||||||
|
}
|
||||||
78
apps/web/src/entities/weather/lib/weatherUtils.ts
Normal file
78
apps/web/src/entities/weather/lib/weatherUtils.ts
Normal file
@ -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];
|
||||||
|
}
|
||||||
40
apps/web/src/entities/weather/model/types.ts
Normal file
40
apps/web/src/entities/weather/model/types.ts
Normal file
@ -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,50 +153,60 @@ 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) {
|
||||||
|
if (cancelled || generation !== generationRef.current) return;
|
||||||
|
|
||||||
|
const { inserted, upserted } = upsertByMmsi(storeRef.current, res.data);
|
||||||
|
const deleted = pruneStore(storeRef.current, retentionMinutes, bbox);
|
||||||
|
const total = storeRef.current.size;
|
||||||
|
|
||||||
|
setSnapshot({
|
||||||
|
status: "ready",
|
||||||
|
error: null,
|
||||||
|
lastFetchAt: new Date().toISOString(),
|
||||||
|
lastFetchMinutes: minutes,
|
||||||
|
lastMessage: res.message,
|
||||||
|
total,
|
||||||
|
lastUpserted: upserted,
|
||||||
|
lastInserted: inserted,
|
||||||
|
lastDeleted: deleted,
|
||||||
|
});
|
||||||
|
setRev((r) => r + 1);
|
||||||
|
}
|
||||||
|
|
||||||
|
async function runInitial(minutes: number) {
|
||||||
try {
|
try {
|
||||||
setSnapshot((s) => ({ ...s, status: s.status === "idle" ? "loading" : s.status, error: null }));
|
setSnapshot((s) => ({ ...s, status: "loading", error: null }));
|
||||||
|
const res = await searchChnprmship({ minutes }, controller.signal);
|
||||||
const res = await searchAisTargets(
|
applyResult(res, minutes);
|
||||||
{
|
|
||||||
minutes,
|
|
||||||
bbox,
|
|
||||||
centerLon,
|
|
||||||
centerLat,
|
|
||||||
radiusMeters,
|
|
||||||
},
|
|
||||||
controller.signal,
|
|
||||||
);
|
|
||||||
if (cancelled || generation !== generationRef.current) return;
|
|
||||||
|
|
||||||
const { inserted, upserted } = upsertByMmsi(storeRef.current, res.data);
|
|
||||||
const deleted = pruneStore(storeRef.current, retentionMinutes, bbox);
|
|
||||||
const total = storeRef.current.size;
|
|
||||||
const lastFetchAt = new Date().toISOString();
|
|
||||||
|
|
||||||
setSnapshot({
|
|
||||||
status: "ready",
|
|
||||||
error: null,
|
|
||||||
lastFetchAt,
|
|
||||||
lastFetchMinutes: minutes,
|
|
||||||
lastMessage: res.message,
|
|
||||||
total,
|
|
||||||
lastUpserted: upserted,
|
|
||||||
lastInserted: inserted,
|
|
||||||
lastDeleted: deleted,
|
|
||||||
});
|
|
||||||
setRev((r) => r + 1);
|
|
||||||
} 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,
|
||||||
|
|||||||
240
apps/web/src/features/liveRenderer/core/ShipBatchRenderer.ts
Normal file
240
apps/web/src/features/liveRenderer/core/ShipBatchRenderer.ts
Normal file
@ -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 };
|
||||||
|
}
|
||||||
63
apps/web/src/features/liveRenderer/lib/adapters.ts
Normal file
63
apps/web/src/features/liveRenderer/lib/adapters.ts
Normal file
@ -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;
|
||||||
|
}
|
||||||
42
apps/web/src/features/liveRenderer/model/liveShip.types.ts
Normal file
42
apps/web/src/features/liveRenderer/model/liveShip.types.ts
Normal file
@ -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;
|
||||||
|
}
|
||||||
221
apps/web/src/features/mapSettings/MapSettingsPanel.tsx
Normal file
221
apps/web/src/features/mapSettings/MapSettingsPanel.tsx
Normal file
@ -0,0 +1,221 @@
|
|||||||
|
import { useState } from 'react';
|
||||||
|
import type { MapStyleSettings, MapLabelLanguage, DepthFontSize, DepthColorStop } from './types';
|
||||||
|
import { DEFAULT_MAP_STYLE_SETTINGS } from './types';
|
||||||
|
|
||||||
|
interface MapSettingsPanelProps {
|
||||||
|
value: MapStyleSettings;
|
||||||
|
onChange: (next: MapStyleSettings) => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
const LANGUAGES: { value: MapLabelLanguage; label: string }[] = [
|
||||||
|
{ value: 'ko', label: '한국어' },
|
||||||
|
{ value: 'en', label: 'English' },
|
||||||
|
{ value: 'ja', label: '日本語' },
|
||||||
|
{ value: 'zh', label: '中文' },
|
||||||
|
{ value: 'local', label: '현지어' },
|
||||||
|
];
|
||||||
|
|
||||||
|
const FONT_SIZES: { value: DepthFontSize; label: string }[] = [
|
||||||
|
{ value: 'small', label: '소' },
|
||||||
|
{ value: 'medium', label: '중' },
|
||||||
|
{ value: 'large', label: '대' },
|
||||||
|
];
|
||||||
|
|
||||||
|
function depthLabel(depth: number): string {
|
||||||
|
return `${Math.abs(depth).toLocaleString()}m`;
|
||||||
|
}
|
||||||
|
|
||||||
|
function hexToRgb(hex: string): [number, number, number] {
|
||||||
|
return [
|
||||||
|
parseInt(hex.slice(1, 3), 16),
|
||||||
|
parseInt(hex.slice(3, 5), 16),
|
||||||
|
parseInt(hex.slice(5, 7), 16),
|
||||||
|
];
|
||||||
|
}
|
||||||
|
|
||||||
|
function rgbToHex(r: number, g: number, b: number): string {
|
||||||
|
return `#${[r, g, b].map((c) => Math.round(Math.max(0, Math.min(255, c))).toString(16).padStart(2, '0')).join('')}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
function interpolateGradient(stops: DepthColorStop[]): DepthColorStop[] {
|
||||||
|
if (stops.length < 2) return stops;
|
||||||
|
const sorted = [...stops].sort((a, b) => a.depth - b.depth);
|
||||||
|
const first = sorted[0];
|
||||||
|
const last = sorted[sorted.length - 1];
|
||||||
|
const [r1, g1, b1] = hexToRgb(first.color);
|
||||||
|
const [r2, g2, b2] = hexToRgb(last.color);
|
||||||
|
return sorted.map((stop, i) => {
|
||||||
|
if (i === 0 || i === sorted.length - 1) return stop;
|
||||||
|
const t = i / (sorted.length - 1);
|
||||||
|
return {
|
||||||
|
depth: stop.depth,
|
||||||
|
color: rgbToHex(
|
||||||
|
r1 + (r2 - r1) * t,
|
||||||
|
g1 + (g2 - g1) * t,
|
||||||
|
b1 + (b2 - b1) * t,
|
||||||
|
),
|
||||||
|
};
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
export function MapSettingsPanel({ value, onChange }: MapSettingsPanelProps) {
|
||||||
|
const [open, setOpen] = useState(false);
|
||||||
|
const [autoGradient, setAutoGradient] = useState(false);
|
||||||
|
|
||||||
|
const update = <K extends keyof MapStyleSettings>(key: K, val: MapStyleSettings[K]) => {
|
||||||
|
onChange({ ...value, [key]: val });
|
||||||
|
};
|
||||||
|
|
||||||
|
const updateDepthStop = (index: number, color: string) => {
|
||||||
|
const next = value.depthStops.map((s, i) => (i === index ? { ...s, color } : s));
|
||||||
|
if (autoGradient && (index === 0 || index === next.length - 1)) {
|
||||||
|
update('depthStops', interpolateGradient(next));
|
||||||
|
} else {
|
||||||
|
update('depthStops', next);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const toggleAutoGradient = () => {
|
||||||
|
const next = !autoGradient;
|
||||||
|
setAutoGradient(next);
|
||||||
|
if (next) {
|
||||||
|
update('depthStops', interpolateGradient(value.depthStops));
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
<button
|
||||||
|
className={`map-settings-gear${open ? ' open' : ''}`}
|
||||||
|
onClick={() => setOpen((p) => !p)}
|
||||||
|
title="지도 설정"
|
||||||
|
type="button"
|
||||||
|
>
|
||||||
|
⚙
|
||||||
|
</button>
|
||||||
|
|
||||||
|
{open && (
|
||||||
|
<div className="map-settings-panel">
|
||||||
|
<div className="ms-title">지도 설정</div>
|
||||||
|
|
||||||
|
{/* ── Language ──────────────────────────────────── */}
|
||||||
|
<div className="ms-section">
|
||||||
|
<div className="ms-label">레이블 언어</div>
|
||||||
|
<select
|
||||||
|
value={value.labelLanguage}
|
||||||
|
onChange={(e) => update('labelLanguage', e.target.value as MapLabelLanguage)}
|
||||||
|
>
|
||||||
|
{LANGUAGES.map((l) => (
|
||||||
|
<option key={l.value} value={l.value}>
|
||||||
|
{l.label}
|
||||||
|
</option>
|
||||||
|
))}
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* ── Land color ────────────────────────────────── */}
|
||||||
|
<div className="ms-section">
|
||||||
|
<div className="ms-label">육지 색상</div>
|
||||||
|
<div className="ms-row">
|
||||||
|
<input
|
||||||
|
type="color"
|
||||||
|
className="ms-color-input"
|
||||||
|
value={value.landColor}
|
||||||
|
onChange={(e) => update('landColor', e.target.value)}
|
||||||
|
/>
|
||||||
|
<span className="ms-hex">{value.landColor}</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* ── Water color ───────────────────────────────── */}
|
||||||
|
<div className="ms-section">
|
||||||
|
<div className="ms-label">물 기본색</div>
|
||||||
|
<div className="ms-row">
|
||||||
|
<input
|
||||||
|
type="color"
|
||||||
|
className="ms-color-input"
|
||||||
|
value={value.waterBaseColor}
|
||||||
|
onChange={(e) => update('waterBaseColor', e.target.value)}
|
||||||
|
/>
|
||||||
|
<span className="ms-hex">{value.waterBaseColor}</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* ── Depth gradient ────────────────────────────── */}
|
||||||
|
<div className="ms-section">
|
||||||
|
<div className="ms-label" style={{ display: 'flex', alignItems: 'center', justifyContent: 'space-between' }}>
|
||||||
|
수심 구간 색상
|
||||||
|
<span
|
||||||
|
className={`tog-btn${autoGradient ? ' on' : ''}`}
|
||||||
|
style={{ fontSize: 8, padding: '1px 5px', marginLeft: 8 }}
|
||||||
|
onClick={toggleAutoGradient}
|
||||||
|
title="최소/최대 색상 기준으로 중간 구간을 자동 보간합니다"
|
||||||
|
>
|
||||||
|
자동채우기
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
{value.depthStops.map((stop, i) => {
|
||||||
|
const isEdge = i === 0 || i === value.depthStops.length - 1;
|
||||||
|
const dimmed = autoGradient && !isEdge;
|
||||||
|
return (
|
||||||
|
<div className="ms-row" key={stop.depth} style={dimmed ? { opacity: 0.5 } : undefined}>
|
||||||
|
<span className="ms-depth-label">{depthLabel(stop.depth)}</span>
|
||||||
|
<input
|
||||||
|
type="color"
|
||||||
|
className="ms-color-input"
|
||||||
|
value={stop.color}
|
||||||
|
onChange={(e) => updateDepthStop(i, e.target.value)}
|
||||||
|
disabled={dimmed}
|
||||||
|
/>
|
||||||
|
<span className="ms-hex">{stop.color}</span>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* ── Depth font size ───────────────────────────── */}
|
||||||
|
<div className="ms-section">
|
||||||
|
<div className="ms-label">수심 폰트 크기</div>
|
||||||
|
<div className="tog" style={{ gap: 3 }}>
|
||||||
|
{FONT_SIZES.map((fs) => (
|
||||||
|
<div
|
||||||
|
key={fs.value}
|
||||||
|
className={`tog-btn${value.depthFontSize === fs.value ? ' on' : ''}`}
|
||||||
|
onClick={() => update('depthFontSize', fs.value)}
|
||||||
|
>
|
||||||
|
{fs.label}
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* ── Depth font color ──────────────────────────── */}
|
||||||
|
<div className="ms-section">
|
||||||
|
<div className="ms-label">수심 폰트 색상</div>
|
||||||
|
<div className="ms-row">
|
||||||
|
<input
|
||||||
|
type="color"
|
||||||
|
className="ms-color-input"
|
||||||
|
value={value.depthFontColor}
|
||||||
|
onChange={(e) => update('depthFontColor', e.target.value)}
|
||||||
|
/>
|
||||||
|
<span className="ms-hex">{value.depthFontColor}</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* ── Reset ─────────────────────────────────────── */}
|
||||||
|
<button
|
||||||
|
className="ms-reset"
|
||||||
|
type="button"
|
||||||
|
onClick={() => {
|
||||||
|
onChange(DEFAULT_MAP_STYLE_SETTINGS);
|
||||||
|
setAutoGradient(false);
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
초기화
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
}
|
||||||
32
apps/web/src/features/mapSettings/types.ts
Normal file
32
apps/web/src/features/mapSettings/types.ts
Normal file
@ -0,0 +1,32 @@
|
|||||||
|
export type MapLabelLanguage = 'ko' | 'en' | 'ja' | 'zh' | 'local';
|
||||||
|
export type DepthFontSize = 'small' | 'medium' | 'large';
|
||||||
|
|
||||||
|
export interface DepthColorStop {
|
||||||
|
depth: number;
|
||||||
|
color: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface MapStyleSettings {
|
||||||
|
labelLanguage: MapLabelLanguage;
|
||||||
|
landColor: string;
|
||||||
|
waterBaseColor: string;
|
||||||
|
depthStops: DepthColorStop[];
|
||||||
|
depthFontSize: DepthFontSize;
|
||||||
|
depthFontColor: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export const DEFAULT_MAP_STYLE_SETTINGS: MapStyleSettings = {
|
||||||
|
labelLanguage: 'ko',
|
||||||
|
landColor: '#1a1a2e',
|
||||||
|
waterBaseColor: '#14606e',
|
||||||
|
depthStops: [
|
||||||
|
{ depth: -8000, color: '#010610' },
|
||||||
|
{ depth: -4000, color: '#030c1c' },
|
||||||
|
{ depth: -2000, color: '#041022' },
|
||||||
|
{ depth: -1000, color: '#051529' },
|
||||||
|
{ depth: -500, color: '#061a30' },
|
||||||
|
{ depth: -100, color: '#08263d' },
|
||||||
|
],
|
||||||
|
depthFontSize: 'medium',
|
||||||
|
depthFontColor: '#e2e8f0',
|
||||||
|
};
|
||||||
@ -6,6 +6,7 @@ export type MapToggleState = {
|
|||||||
fleetCircles: boolean;
|
fleetCircles: boolean;
|
||||||
predictVectors: boolean;
|
predictVectors: boolean;
|
||||||
shipLabels: boolean;
|
shipLabels: boolean;
|
||||||
|
subcables: boolean;
|
||||||
};
|
};
|
||||||
|
|
||||||
type Props = {
|
type Props = {
|
||||||
@ -22,6 +23,7 @@ export function MapToggles({ value, onToggle }: Props) {
|
|||||||
{ id: "zones", label: "수역 표시" },
|
{ id: "zones", label: "수역 표시" },
|
||||||
{ id: "predictVectors", label: "예측 벡터" },
|
{ id: "predictVectors", label: "예측 벡터" },
|
||||||
{ id: "shipLabels", label: "선박명 표시" },
|
{ id: "shipLabels", label: "선박명 표시" },
|
||||||
|
{ id: "subcables", label: "해저케이블" },
|
||||||
];
|
];
|
||||||
|
|
||||||
return (
|
return (
|
||||||
|
|||||||
@ -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,
|
||||||
|
};
|
||||||
|
}
|
||||||
60
apps/web/src/features/trackReplay/layers/replayLayers.ts
Normal file
60
apps/web/src/features/trackReplay/layers/replayLayers.ts
Normal file
@ -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,
|
||||||
|
});
|
||||||
|
}
|
||||||
200
apps/web/src/features/trackReplay/layers/trackLayers.ts
Normal file
200
apps/web/src/features/trackReplay/layers/trackLayers.ts
Normal file
@ -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-');
|
||||||
|
}
|
||||||
159
apps/web/src/features/trackReplay/lib/adapters.ts
Normal file
159
apps/web/src/features/trackReplay/lib/adapters.ts
Normal file
@ -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'];
|
||||||
|
}
|
||||||
91
apps/web/src/features/trackReplay/lib/interpolate.ts
Normal file
91
apps/web/src/features/trackReplay/lib/interpolate.ts
Normal file
@ -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;
|
||||||
|
}
|
||||||
45
apps/web/src/features/trackReplay/model/track.types.ts
Normal file
45
apps/web/src/features/trackReplay/model/track.types.ts
Normal file
@ -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();
|
||||||
162
apps/web/src/features/trackReplay/services/trackQueryService.ts
Normal file
162
apps/web/src/features/trackReplay/services/trackQueryService.ts
Normal file
@ -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);
|
||||||
|
}
|
||||||
162
apps/web/src/features/trackReplay/stores/trackPlaybackStore.ts
Normal file
162
apps/web/src/features/trackReplay/stores/trackPlaybackStore.ts
Normal file
@ -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;
|
||||||
210
apps/web/src/features/trackReplay/stores/trackQueryStore.ts
Normal file
210
apps/web/src/features/trackReplay/stores/trackQueryStore.ts
Normal file
@ -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,
|
||||||
|
}));
|
||||||
|
},
|
||||||
|
}));
|
||||||
415
apps/web/src/features/weatherOverlay/useWeatherOverlay.ts
Normal file
415
apps/web/src/features/weatherOverlay/useWeatherOverlay.ts
Normal file
@ -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,
|
||||||
|
};
|
||||||
|
}
|
||||||
97
apps/web/src/features/weatherOverlay/useWeatherPolling.ts
Normal file
97
apps/web/src/features/weatherOverlay/useWeatherPolling.ts
Normal file
@ -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";
|
||||||
@ -11,17 +13,32 @@ import { buildLegacyVesselIndex } from "../../entities/legacyVessel/lib";
|
|||||||
import type { LegacyVesselIndex } from "../../entities/legacyVessel/lib";
|
import type { LegacyVesselIndex } from "../../entities/legacyVessel/lib";
|
||||||
import type { LegacyVesselDataset } from "../../entities/legacyVessel/model/types";
|
import type { LegacyVesselDataset } from "../../entities/legacyVessel/model/types";
|
||||||
import { useZones } from "../../entities/zone/api/useZones";
|
import { useZones } from "../../entities/zone/api/useZones";
|
||||||
|
import { useSubcables } from "../../entities/subcable/api/useSubcables";
|
||||||
import type { VesselTypeCode } from "../../entities/vessel/model/types";
|
import type { VesselTypeCode } from "../../entities/vessel/model/types";
|
||||||
import { AisInfoPanel } from "../../widgets/aisInfo/AisInfoPanel";
|
import { AisInfoPanel } from "../../widgets/aisInfo/AisInfoPanel";
|
||||||
import { AisTargetList } from "../../widgets/aisTargetList/AisTargetList";
|
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";
|
||||||
import { VesselInfoPanel } from "../../widgets/info/VesselInfoPanel";
|
import { VesselInfoPanel } from "../../widgets/info/VesselInfoPanel";
|
||||||
|
import { SubcableInfoPanel } from "../../widgets/subcableInfo/SubcableInfoPanel";
|
||||||
import { VesselList } from "../../widgets/vesselList/VesselList";
|
import { VesselList } from "../../widgets/vesselList/VesselList";
|
||||||
|
import { MapSettingsPanel } from "../../features/mapSettings/MapSettingsPanel";
|
||||||
|
import { DepthLegend } from "../../widgets/legend/DepthLegend";
|
||||||
|
import { DEFAULT_MAP_STYLE_SETTINGS } 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,
|
||||||
@ -41,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";
|
||||||
|
|
||||||
@ -68,21 +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 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,
|
||||||
@ -95,48 +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);
|
|
||||||
|
|
||||||
const [baseMap, setBaseMap] = useState<BaseMapId>("enhanced");
|
// 레거시 베이스맵 비활성 — 향후 위성/라이트 등 추가 시 재활용
|
||||||
const [projection, setProjection] = useState<MapProjectionId>("mercator");
|
// eslint-disable-next-line @typescript-eslint/no-unused-vars
|
||||||
|
const [baseMap, _setBaseMap] = useState<BaseMapId>("enhanced");
|
||||||
|
// 항상 mercator로 시작 — 3D 전환은 globe 레이어 준비 후 사용자가 수동 전환
|
||||||
|
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: false,
|
|
||||||
shipLabels: 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 [settings, setSettings] = useState<Map3DSettings>({
|
const [hoveredCableId, setHoveredCableId] = useState<string | null>(null);
|
||||||
showShips: true,
|
const [selectedCableId, setSelectedCableId] = useState<string | null>(null);
|
||||||
showDensity: false,
|
|
||||||
showSeamark: false,
|
// 항적 (vessel track)
|
||||||
|
const [trackContextMenu, setTrackContextMenu] = useState<{ x: number; y: number; mmsi: number; vesselName: string } | null>(null);
|
||||||
|
const handleOpenTrackMenu = useCallback((info: { x: number; y: number; mmsi: number; vesselName: string }) => {
|
||||||
|
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,
|
||||||
|
});
|
||||||
|
|
||||||
|
if (tracks.length > 0) {
|
||||||
|
trackStore.applyTracksSuccess(tracks, queryKey);
|
||||||
|
} else {
|
||||||
|
trackStore.applyQueryError('항적 데이터가 없습니다.', queryKey);
|
||||||
|
}
|
||||||
|
} catch (e) {
|
||||||
|
trackStore.applyQueryError(e instanceof Error ? e.message : '항적 조회에 실패했습니다.', queryKey);
|
||||||
|
}
|
||||||
|
}, [targets]);
|
||||||
|
|
||||||
|
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);
|
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(() => new Date().toLocaleString("ko-KR", { hour12: false }));
|
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);
|
||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
@ -296,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">
|
||||||
@ -345,30 +400,28 @@ export function DashboardPage() {
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="sb">
|
<div className="sb">
|
||||||
<div className="sb-t">지도 표시 설정</div>
|
<div className="sb-t" style={{ display: "flex", alignItems: "center" }}>
|
||||||
<MapToggles value={overlays} onToggle={(k) => setOverlays((prev) => ({ ...prev, [k]: !prev[k] }))} />
|
지도 표시 설정
|
||||||
<div style={{ fontSize: 9, fontWeight: 700, color: "var(--muted)", letterSpacing: 1.5, marginTop: 8, marginBottom: 6 }}>
|
<div style={{ flex: 1 }} />
|
||||||
베이스맵
|
<div
|
||||||
|
className={`tog-btn ${projection === "globe" ? "on" : ""}${isProjectionToggleDisabled ? " disabled" : ""}`}
|
||||||
|
onClick={isProjectionToggleDisabled ? undefined : () => setProjection((p) => (p === "globe" ? "mercator" : "globe"))}
|
||||||
|
title={isProjectionToggleDisabled ? "3D 모드 준비 중..." : "3D 지구본 투영: 드래그로 회전, 휠로 확대/축소"}
|
||||||
|
style={{ fontSize: 9, padding: "2px 8px", opacity: isProjectionToggleDisabled ? 0.4 : 1, cursor: isProjectionToggleDisabled ? "not-allowed" : "pointer" }}
|
||||||
|
>
|
||||||
|
3D
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div className="tog" style={{ flexWrap: "nowrap", alignItems: "center" }}>
|
<MapToggles value={overlays} onToggle={(k) => setOverlays((prev) => ({ ...prev, [k]: !prev[k] }))} />
|
||||||
|
{/* 베이스맵 선택 — 현재 enhanced 단일 맵 사용, 레거시는 비활성
|
||||||
|
<div className="tog" style={{ flexWrap: "nowrap", alignItems: "center", marginTop: 8 }}>
|
||||||
<div className={`tog-btn ${baseMap === "enhanced" ? "on" : ""}`} onClick={() => setBaseMap("enhanced")} title="현재 지도(해저지형/3D 표현 포함)">
|
<div className={`tog-btn ${baseMap === "enhanced" ? "on" : ""}`} onClick={() => setBaseMap("enhanced")} title="현재 지도(해저지형/3D 표현 포함)">
|
||||||
기본
|
기본
|
||||||
</div>
|
</div>
|
||||||
<div className={`tog-btn ${baseMap === "legacy" ? "on" : ""}`} onClick={() => setBaseMap("legacy")} title="레거시 대시보드(Carto Dark) 베이스맵">
|
<div className={`tog-btn ${baseMap === "legacy" ? "on" : ""}`} onClick={() => setBaseMap("legacy")} title="레거시 대시보드(Carto Dark) 베이스맵">
|
||||||
레거시
|
레거시
|
||||||
</div>
|
</div>
|
||||||
|
</div> */}
|
||||||
<div style={{ flex: 1 }} />
|
|
||||||
|
|
||||||
<div
|
|
||||||
className={`tog-btn ${projection === "globe" ? "on" : ""}`}
|
|
||||||
onClick={() => setProjection((p) => (p === "globe" ? "mercator" : "globe"))}
|
|
||||||
title="지구본(globe) 투영: 드래그로 회전, 휠로 확대/축소"
|
|
||||||
>
|
|
||||||
지구본
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
{/* Attribution (license) stays visible in the map UI; no need to repeat it here. */}
|
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="sb">
|
<div className="sb">
|
||||||
@ -531,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>
|
||||||
@ -555,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>
|
||||||
@ -666,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" />
|
||||||
@ -698,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))}
|
||||||
@ -711,13 +765,43 @@ export function DashboardPage() {
|
|||||||
setHoveredFleetOwnerKey(null);
|
setHoveredFleetOwnerKey(null);
|
||||||
setHoveredFleetMmsiSet((prev) => (prev.length === 0 ? prev : []));
|
setHoveredFleetMmsiSet((prev) => (prev.length === 0 ? prev : []));
|
||||||
}}
|
}}
|
||||||
|
subcableGeo={subcableData?.geo ?? null}
|
||||||
|
hoveredCableId={hoveredCableId}
|
||||||
|
onHoverCable={setHoveredCableId}
|
||||||
|
onClickCable={(id) => setSelectedCableId((prev) => (prev === id ? null : id))}
|
||||||
|
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} />
|
||||||
|
<DepthLegend depthStops={mapStyleSettings.depthStops} />
|
||||||
<MapLegend />
|
<MapLegend />
|
||||||
{selectedLegacyVessel ? (
|
{selectedLegacyVessel ? (
|
||||||
<VesselInfoPanel vessel={selectedLegacyVessel} allVessels={legacyVesselsAll} onClose={() => setSelectedMmsi(null)} onSelectMmsi={setSelectedMmsi} />
|
<VesselInfoPanel vessel={selectedLegacyVessel} allVessels={legacyVesselsAll} onClose={() => setSelectedMmsi(null)} onSelectMmsi={setSelectedMmsi} />
|
||||||
) : selectedTarget ? (
|
) : selectedTarget ? (
|
||||||
<AisInfoPanel target={selectedTarget} legacy={selectedLegacyInfo} onClose={() => setSelectedMmsi(null)} />
|
<AisInfoPanel target={selectedTarget} legacy={selectedLegacyInfo} onClose={() => setSelectedMmsi(null)} />
|
||||||
) : null}
|
) : null}
|
||||||
|
{selectedCableId && subcableData?.details.get(selectedCableId) ? (
|
||||||
|
<SubcableInfoPanel
|
||||||
|
detail={subcableData.details.get(selectedCableId)!}
|
||||||
|
color={subcableData.geo.features.find((f) => f.properties.id === selectedCableId)?.properties.color}
|
||||||
|
onClose={() => setSelectedCableId(null)}
|
||||||
|
/>
|
||||||
|
) : null}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
|
|||||||
44
apps/web/src/pages/denied/DeniedPage.tsx
Normal file
44
apps/web/src/pages/denied/DeniedPage.tsx
Normal file
@ -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">🚫</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>
|
||||||
|
);
|
||||||
|
}
|
||||||
70
apps/web/src/pages/login/LoginPage.tsx
Normal file
70
apps/web/src/pages/login/LoginPage.tsx
Normal file
@ -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 · Wing Fleet Dashboard
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
39
apps/web/src/pages/pending/PendingPage.tsx
Normal file
39
apps/web/src/pages/pending/PendingPage.tsx
Normal file
@ -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">⏳</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>
|
||||||
|
);
|
||||||
|
}
|
||||||
19
apps/web/src/shared/auth/AuthContext.ts
Normal file
19
apps/web/src/shared/auth/AuthContext.ts
Normal file
@ -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: () => {},
|
||||||
|
});
|
||||||
99
apps/web/src/shared/auth/AuthProvider.tsx
Normal file
99
apps/web/src/shared/auth/AuthProvider.tsx
Normal file
@ -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>;
|
||||||
|
}
|
||||||
35
apps/web/src/shared/auth/ProtectedRoute.tsx
Normal file
35
apps/web/src/shared/auth/ProtectedRoute.tsx
Normal file
@ -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 />;
|
||||||
|
}
|
||||||
34
apps/web/src/shared/auth/authApi.ts
Normal file
34
apps/web/src/shared/auth/authApi.ts
Normal file
@ -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) }),
|
||||||
|
};
|
||||||
4
apps/web/src/shared/auth/index.ts
Normal file
4
apps/web/src/shared/auth/index.ts
Normal file
@ -0,0 +1,4 @@
|
|||||||
|
export { AuthProvider } from './AuthProvider';
|
||||||
|
export { useAuth } from './useAuth';
|
||||||
|
export { ProtectedRoute } from './ProtectedRoute';
|
||||||
|
export type { User, Role, AuthResponse, UserStatus } from './types';
|
||||||
25
apps/web/src/shared/auth/types.ts
Normal file
25
apps/web/src/shared/auth/types.ts
Normal file
@ -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;
|
||||||
|
}
|
||||||
6
apps/web/src/shared/auth/useAuth.ts
Normal file
6
apps/web/src/shared/auth/useAuth.ts
Normal file
@ -0,0 +1,6 @@
|
|||||||
|
import { useContext } from 'react';
|
||||||
|
import { AuthContext } from './AuthContext';
|
||||||
|
|
||||||
|
export function useAuth() {
|
||||||
|
return useContext(AuthContext);
|
||||||
|
}
|
||||||
1
apps/web/src/shared/hooks/index.ts
Normal file
1
apps/web/src/shared/hooks/index.ts
Normal file
@ -0,0 +1 @@
|
|||||||
|
export { usePersistedState } from './usePersistedState';
|
||||||
103
apps/web/src/shared/hooks/usePersistedState.ts
Normal file
103
apps/web/src/shared/hooks/usePersistedState.ts
Normal file
@ -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];
|
||||||
|
}
|
||||||
50
apps/web/src/shared/lib/datetime.ts
Normal file
50
apps/web/src/shared/lib/datetime.ts
Normal file
@ -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>
|
||||||
|
|||||||
33
apps/web/src/widgets/legend/DepthLegend.tsx
Normal file
33
apps/web/src/widgets/legend/DepthLegend.tsx
Normal file
@ -0,0 +1,33 @@
|
|||||||
|
import { useMemo } from 'react';
|
||||||
|
import type { DepthColorStop } from '../../features/mapSettings/types';
|
||||||
|
|
||||||
|
interface DepthLegendProps {
|
||||||
|
depthStops: DepthColorStop[];
|
||||||
|
}
|
||||||
|
|
||||||
|
export function DepthLegend({ depthStops }: DepthLegendProps) {
|
||||||
|
const sorted = useMemo(
|
||||||
|
() => [...depthStops].sort((a, b) => a.depth - b.depth),
|
||||||
|
[depthStops],
|
||||||
|
);
|
||||||
|
|
||||||
|
const gradient = useMemo(() => {
|
||||||
|
if (sorted.length === 0) return 'transparent';
|
||||||
|
const stops = sorted.map((s, i) => {
|
||||||
|
const pct = (i / (sorted.length - 1)) * 100;
|
||||||
|
return `${s.color} ${pct.toFixed(0)}%`;
|
||||||
|
});
|
||||||
|
return `linear-gradient(to bottom, ${stops.join(', ')})`;
|
||||||
|
}, [sorted]);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="depth-legend">
|
||||||
|
<div className="depth-legend__bar" style={{ background: gradient }} />
|
||||||
|
<div className="depth-legend__ticks">
|
||||||
|
{sorted.map((s) => (
|
||||||
|
<span key={s.depth}>{Math.abs(s.depth).toLocaleString()}m</span>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
파일 크기가 너무 크기때문에 변경 상태를 표시하지 않습니다.
Load Diff
135
apps/web/src/widgets/map3d/components/VesselContextMenu.tsx
Normal file
135
apps/web/src/widgets/map3d/components/VesselContextMenu.tsx
Normal file
@ -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>
|
||||||
|
);
|
||||||
|
}
|
||||||
161
apps/web/src/widgets/map3d/constants.ts
Normal file
161
apps/web/src/widgets/map3d/constants.ts
Normal file
@ -0,0 +1,161 @@
|
|||||||
|
import {
|
||||||
|
LEGACY_CODE_COLORS_RGB,
|
||||||
|
OVERLAY_RGB,
|
||||||
|
rgba as rgbaCss,
|
||||||
|
} from '../../shared/lib/map/palette';
|
||||||
|
|
||||||
|
// ── Re-export palette aliases used throughout Map3D ──
|
||||||
|
|
||||||
|
export const LEGACY_CODE_COLORS = LEGACY_CODE_COLORS_RGB;
|
||||||
|
|
||||||
|
const OVERLAY_PAIR_NORMAL_RGB = OVERLAY_RGB.pairNormal;
|
||||||
|
const OVERLAY_PAIR_WARN_RGB = OVERLAY_RGB.pairWarn;
|
||||||
|
const OVERLAY_FC_TRANSFER_RGB = OVERLAY_RGB.fcTransfer;
|
||||||
|
const OVERLAY_FLEET_RANGE_RGB = OVERLAY_RGB.fleetRange;
|
||||||
|
const OVERLAY_SUSPICIOUS_RGB = OVERLAY_RGB.suspicious;
|
||||||
|
|
||||||
|
// ── Ship icon mapping (Deck.gl IconLayer) ──
|
||||||
|
|
||||||
|
export const SHIP_ICON_MAPPING = {
|
||||||
|
ship: {
|
||||||
|
x: 0,
|
||||||
|
y: 0,
|
||||||
|
width: 128,
|
||||||
|
height: 128,
|
||||||
|
anchorX: 64,
|
||||||
|
anchorY: 64,
|
||||||
|
mask: true,
|
||||||
|
},
|
||||||
|
} as const;
|
||||||
|
|
||||||
|
// ── Ship constants ──
|
||||||
|
|
||||||
|
export const ANCHOR_SPEED_THRESHOLD_KN = 1;
|
||||||
|
export const ANCHORED_SHIP_ICON_ID = 'ship-globe-anchored-icon';
|
||||||
|
|
||||||
|
// ── Geometry constants ──
|
||||||
|
|
||||||
|
export const DEG2RAD = Math.PI / 180;
|
||||||
|
export const RAD2DEG = 180 / Math.PI;
|
||||||
|
export const EARTH_RADIUS_M = 6371008.8; // MapLibre's `earthRadius`
|
||||||
|
export const GLOBE_ICON_HEADING_OFFSET_DEG = 0;
|
||||||
|
|
||||||
|
// ── Ship color constants ──
|
||||||
|
|
||||||
|
export const MAP_SELECTED_SHIP_RGB: [number, number, number] = [34, 211, 238];
|
||||||
|
export const MAP_HIGHLIGHT_SHIP_RGB: [number, number, number] = [245, 158, 11];
|
||||||
|
export const MAP_DEFAULT_SHIP_RGB: [number, number, number] = [100, 116, 139];
|
||||||
|
|
||||||
|
// ── Ship outline / halo contrast colors ──
|
||||||
|
|
||||||
|
export const HALO_OUTLINE_COLOR: [number, number, number, number] = [210, 225, 240, 155];
|
||||||
|
export const HALO_OUTLINE_COLOR_SELECTED: [number, number, number, number] = [14, 234, 255, 230];
|
||||||
|
export const HALO_OUTLINE_COLOR_HIGHLIGHTED: [number, number, number, number] = [245, 158, 11, 210];
|
||||||
|
export const GLOBE_OUTLINE_PERMITTED = 'rgba(210,225,240,0.62)';
|
||||||
|
export const GLOBE_OUTLINE_OTHER = 'rgba(160,175,195,0.35)';
|
||||||
|
|
||||||
|
// ── Flat map icon sizes ──
|
||||||
|
|
||||||
|
export const FLAT_SHIP_ICON_SIZE = 19;
|
||||||
|
export const FLAT_SHIP_ICON_SIZE_SELECTED = 28;
|
||||||
|
export const FLAT_SHIP_ICON_SIZE_HIGHLIGHTED = 25;
|
||||||
|
export const FLAT_LEGACY_HALO_RADIUS = 14;
|
||||||
|
export const FLAT_LEGACY_HALO_RADIUS_SELECTED = 18;
|
||||||
|
export const FLAT_LEGACY_HALO_RADIUS_HIGHLIGHTED = 16;
|
||||||
|
export const EMPTY_MMSI_SET = new Set<number>();
|
||||||
|
|
||||||
|
// ── Deck.gl view ID ──
|
||||||
|
|
||||||
|
export const DECK_VIEW_ID = 'mapbox';
|
||||||
|
|
||||||
|
// ── Depth params ──
|
||||||
|
|
||||||
|
export const DEPTH_DISABLED_PARAMS = {
|
||||||
|
depthCompare: 'always',
|
||||||
|
depthWriteEnabled: false,
|
||||||
|
} as const;
|
||||||
|
|
||||||
|
export const GLOBE_OVERLAY_PARAMS = {
|
||||||
|
depthCompare: 'less-equal',
|
||||||
|
depthWriteEnabled: false,
|
||||||
|
} as const;
|
||||||
|
|
||||||
|
// ── Deck.gl color constants (avoid per-object allocations inside accessors) ──
|
||||||
|
|
||||||
|
export const PAIR_RANGE_NORMAL_DECK: [number, number, number, number] = [
|
||||||
|
OVERLAY_PAIR_NORMAL_RGB[0], OVERLAY_PAIR_NORMAL_RGB[1], OVERLAY_PAIR_NORMAL_RGB[2], 110,
|
||||||
|
];
|
||||||
|
export const PAIR_RANGE_WARN_DECK: [number, number, number, number] = [
|
||||||
|
OVERLAY_PAIR_WARN_RGB[0], OVERLAY_PAIR_WARN_RGB[1], OVERLAY_PAIR_WARN_RGB[2], 170,
|
||||||
|
];
|
||||||
|
export const PAIR_LINE_NORMAL_DECK: [number, number, number, number] = [
|
||||||
|
OVERLAY_PAIR_NORMAL_RGB[0], OVERLAY_PAIR_NORMAL_RGB[1], OVERLAY_PAIR_NORMAL_RGB[2], 85,
|
||||||
|
];
|
||||||
|
export const PAIR_LINE_WARN_DECK: [number, number, number, number] = [
|
||||||
|
OVERLAY_PAIR_WARN_RGB[0], OVERLAY_PAIR_WARN_RGB[1], OVERLAY_PAIR_WARN_RGB[2], 220,
|
||||||
|
];
|
||||||
|
export const FC_LINE_NORMAL_DECK: [number, number, number, number] = [
|
||||||
|
OVERLAY_FC_TRANSFER_RGB[0], OVERLAY_FC_TRANSFER_RGB[1], OVERLAY_FC_TRANSFER_RGB[2], 200,
|
||||||
|
];
|
||||||
|
export const FC_LINE_SUSPICIOUS_DECK: [number, number, number, number] = [
|
||||||
|
OVERLAY_SUSPICIOUS_RGB[0], OVERLAY_SUSPICIOUS_RGB[1], OVERLAY_SUSPICIOUS_RGB[2], 220,
|
||||||
|
];
|
||||||
|
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,
|
||||||
|
];
|
||||||
|
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], 0,
|
||||||
|
];
|
||||||
|
|
||||||
|
// ── Highlighted variants ──
|
||||||
|
|
||||||
|
export const PAIR_RANGE_NORMAL_DECK_HL: [number, number, number, number] = [
|
||||||
|
OVERLAY_PAIR_NORMAL_RGB[0], OVERLAY_PAIR_NORMAL_RGB[1], OVERLAY_PAIR_NORMAL_RGB[2], 200,
|
||||||
|
];
|
||||||
|
export const PAIR_RANGE_WARN_DECK_HL: [number, number, number, number] = [
|
||||||
|
OVERLAY_PAIR_WARN_RGB[0], OVERLAY_PAIR_WARN_RGB[1], OVERLAY_PAIR_WARN_RGB[2], 240,
|
||||||
|
];
|
||||||
|
export const PAIR_LINE_NORMAL_DECK_HL: [number, number, number, number] = [
|
||||||
|
OVERLAY_PAIR_NORMAL_RGB[0], OVERLAY_PAIR_NORMAL_RGB[1], OVERLAY_PAIR_NORMAL_RGB[2], 245,
|
||||||
|
];
|
||||||
|
export const PAIR_LINE_WARN_DECK_HL: [number, number, number, number] = [
|
||||||
|
OVERLAY_PAIR_WARN_RGB[0], OVERLAY_PAIR_WARN_RGB[1], OVERLAY_PAIR_WARN_RGB[2], 245,
|
||||||
|
];
|
||||||
|
export const FC_LINE_NORMAL_DECK_HL: [number, number, number, number] = [
|
||||||
|
OVERLAY_FC_TRANSFER_RGB[0], OVERLAY_FC_TRANSFER_RGB[1], OVERLAY_FC_TRANSFER_RGB[2], 235,
|
||||||
|
];
|
||||||
|
export const FC_LINE_SUSPICIOUS_DECK_HL: [number, number, number, number] = [
|
||||||
|
OVERLAY_SUSPICIOUS_RGB[0], OVERLAY_SUSPICIOUS_RGB[1], OVERLAY_SUSPICIOUS_RGB[2], 245,
|
||||||
|
];
|
||||||
|
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,
|
||||||
|
];
|
||||||
|
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], 120,
|
||||||
|
];
|
||||||
|
|
||||||
|
// ── MapLibre overlay colors ──
|
||||||
|
|
||||||
|
export const PAIR_LINE_NORMAL_ML = rgbaCss(OVERLAY_PAIR_NORMAL_RGB, 0.55);
|
||||||
|
export const PAIR_LINE_WARN_ML = rgbaCss(OVERLAY_PAIR_WARN_RGB, 0.95);
|
||||||
|
export const PAIR_LINE_NORMAL_ML_HL = rgbaCss(OVERLAY_PAIR_NORMAL_RGB, 0.95);
|
||||||
|
export const PAIR_LINE_WARN_ML_HL = rgbaCss(OVERLAY_PAIR_WARN_RGB, 0.98);
|
||||||
|
|
||||||
|
export const PAIR_RANGE_NORMAL_ML = rgbaCss(OVERLAY_PAIR_NORMAL_RGB, 0.45);
|
||||||
|
export const PAIR_RANGE_WARN_ML = rgbaCss(OVERLAY_PAIR_WARN_RGB, 0.75);
|
||||||
|
export const PAIR_RANGE_NORMAL_ML_HL = rgbaCss(OVERLAY_PAIR_NORMAL_RGB, 0.92);
|
||||||
|
export const PAIR_RANGE_WARN_ML_HL = rgbaCss(OVERLAY_PAIR_WARN_RGB, 0.92);
|
||||||
|
|
||||||
|
export const FC_LINE_NORMAL_ML = rgbaCss(OVERLAY_FC_TRANSFER_RGB, 0.92);
|
||||||
|
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_SUSPICIOUS_ML_HL = rgbaCss(OVERLAY_SUSPICIOUS_RGB, 0.98);
|
||||||
|
|
||||||
|
export const FLEET_FILL_ML = rgbaCss(OVERLAY_FLEET_RANGE_RGB, 0.12);
|
||||||
|
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_HL = rgbaCss(OVERLAY_FLEET_RANGE_RGB, 0.95);
|
||||||
|
|
||||||
|
// ── Bathymetry zoom ranges ──
|
||||||
|
// NOTE: BATHY_ZOOM_RANGES는 bathymetry.ts에서 로컬 정의 + applyBathymetryZoomProfile()에서 사용
|
||||||
|
// 이 파일의 export는 사용처가 없어 제거됨 (2026-02-16)
|
||||||
132
apps/web/src/widgets/map3d/hooks/useBaseMapToggle.ts
Normal file
132
apps/web/src/widgets/map3d/hooks/useBaseMapToggle.ts
Normal file
@ -0,0 +1,132 @@
|
|||||||
|
import { useEffect, useRef, type MutableRefObject } from 'react';
|
||||||
|
import type maplibregl from 'maplibre-gl';
|
||||||
|
import type { BaseMapId, MapProjectionId } from '../types';
|
||||||
|
import { kickRepaint, onMapStyleReady, getLayerId } from '../lib/mapCore';
|
||||||
|
import { ensureSeamarkOverlay } from '../layers/seamark';
|
||||||
|
import { applyBathymetryZoomProfile, resolveMapStyle } from '../layers/bathymetry';
|
||||||
|
|
||||||
|
export function useBaseMapToggle(
|
||||||
|
mapRef: MutableRefObject<maplibregl.Map | null>,
|
||||||
|
opts: {
|
||||||
|
baseMap: BaseMapId;
|
||||||
|
projection: MapProjectionId;
|
||||||
|
showSeamark: boolean;
|
||||||
|
mapSyncEpoch: number;
|
||||||
|
pulseMapSync: () => void;
|
||||||
|
},
|
||||||
|
) {
|
||||||
|
const { baseMap, projection, showSeamark, mapSyncEpoch, pulseMapSync } = opts;
|
||||||
|
|
||||||
|
const showSeamarkRef = useRef(showSeamark);
|
||||||
|
const bathyZoomProfileKeyRef = useRef<string>('');
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
showSeamarkRef.current = showSeamark;
|
||||||
|
}, [showSeamark]);
|
||||||
|
|
||||||
|
// Base map style toggle
|
||||||
|
useEffect(() => {
|
||||||
|
const map = mapRef.current;
|
||||||
|
if (!map) return;
|
||||||
|
|
||||||
|
let cancelled = false;
|
||||||
|
const controller = new AbortController();
|
||||||
|
let stop: (() => void) | null = null;
|
||||||
|
|
||||||
|
(async () => {
|
||||||
|
try {
|
||||||
|
const style = await resolveMapStyle(baseMap, controller.signal);
|
||||||
|
if (cancelled) return;
|
||||||
|
map.setStyle(style, { diff: false });
|
||||||
|
stop = onMapStyleReady(map, () => {
|
||||||
|
kickRepaint(map);
|
||||||
|
requestAnimationFrame(() => kickRepaint(map));
|
||||||
|
pulseMapSync();
|
||||||
|
});
|
||||||
|
} catch (e) {
|
||||||
|
if (cancelled) return;
|
||||||
|
console.warn('Base map switch failed:', e);
|
||||||
|
}
|
||||||
|
})();
|
||||||
|
|
||||||
|
return () => {
|
||||||
|
cancelled = true;
|
||||||
|
controller.abort();
|
||||||
|
stop?.();
|
||||||
|
};
|
||||||
|
}, [baseMap]);
|
||||||
|
|
||||||
|
// Bathymetry zoom profile + water layer visibility
|
||||||
|
useEffect(() => {
|
||||||
|
const map = mapRef.current;
|
||||||
|
if (!map) return;
|
||||||
|
|
||||||
|
const apply = () => {
|
||||||
|
if (!map.isStyleLoaded()) return;
|
||||||
|
const seaVisibility = 'visible' as const;
|
||||||
|
const seaRegex = /(water|sea|ocean|river|lake|coast|bay)/i;
|
||||||
|
|
||||||
|
const nextProfileKey = `bathyZoomV1|${baseMap}|${projection}`;
|
||||||
|
if (bathyZoomProfileKeyRef.current !== nextProfileKey) {
|
||||||
|
applyBathymetryZoomProfile(map, baseMap, projection);
|
||||||
|
bathyZoomProfileKeyRef.current = nextProfileKey;
|
||||||
|
kickRepaint(map);
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
const style = map.getStyle();
|
||||||
|
const styleLayers = style && Array.isArray(style.layers) ? style.layers : [];
|
||||||
|
for (const layer of styleLayers) {
|
||||||
|
const id = getLayerId(layer);
|
||||||
|
if (!id) continue;
|
||||||
|
const sourceLayer = String((layer as Record<string, unknown>)['source-layer'] ?? '').toLowerCase();
|
||||||
|
const source = String((layer as { source?: unknown }).source ?? '').toLowerCase();
|
||||||
|
const type = String((layer as { type?: unknown }).type ?? '').toLowerCase();
|
||||||
|
const isSea = seaRegex.test(id) || seaRegex.test(sourceLayer) || seaRegex.test(source);
|
||||||
|
const isRaster = type === 'raster';
|
||||||
|
if (!isSea) continue;
|
||||||
|
if (!map.getLayer(id)) continue;
|
||||||
|
if (isRaster && id === 'seamark') continue;
|
||||||
|
try {
|
||||||
|
map.setLayoutProperty(id, 'visibility', seaVisibility);
|
||||||
|
} catch {
|
||||||
|
// ignore
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} catch {
|
||||||
|
// ignore
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const stop = onMapStyleReady(map, apply);
|
||||||
|
return () => {
|
||||||
|
stop();
|
||||||
|
};
|
||||||
|
}, [projection, baseMap, mapSyncEpoch]);
|
||||||
|
|
||||||
|
// Seamark toggle
|
||||||
|
useEffect(() => {
|
||||||
|
const map = mapRef.current;
|
||||||
|
if (!map) return;
|
||||||
|
if (showSeamark) {
|
||||||
|
try {
|
||||||
|
ensureSeamarkOverlay(map, 'bathymetry-lines-coarse');
|
||||||
|
map.setPaintProperty('seamark', 'raster-opacity', 0.85);
|
||||||
|
} catch {
|
||||||
|
// ignore until style is ready
|
||||||
|
}
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
if (map.getLayer('seamark')) map.removeLayer('seamark');
|
||||||
|
} catch {
|
||||||
|
// ignore
|
||||||
|
}
|
||||||
|
try {
|
||||||
|
if (map.getSource('seamark')) map.removeSource('seamark');
|
||||||
|
} catch {
|
||||||
|
// ignore
|
||||||
|
}
|
||||||
|
}, [showSeamark]);
|
||||||
|
}
|
||||||
678
apps/web/src/widgets/map3d/hooks/useDeckLayers.ts
Normal file
678
apps/web/src/widgets/map3d/hooks/useDeckLayers.ts
Normal file
@ -0,0 +1,678 @@
|
|||||||
|
import { useEffect, useMemo, type MutableRefObject } from 'react';
|
||||||
|
import { HexagonLayer } from '@deck.gl/aggregation-layers';
|
||||||
|
import { IconLayer, LineLayer, ScatterplotLayer } from '@deck.gl/layers';
|
||||||
|
import { MapboxOverlay } from '@deck.gl/mapbox';
|
||||||
|
import { type PickingInfo } from '@deck.gl/core';
|
||||||
|
import type { AisTarget } from '../../../entities/aisTarget/model/types';
|
||||||
|
import type { LegacyVesselInfo } from '../../../entities/legacyVessel/model/types';
|
||||||
|
import type { FcLink, FleetCircle, PairLink } from '../../../features/legacyDashboard/model/types';
|
||||||
|
import type { MapToggleState } from '../../../features/mapToggles/MapToggles';
|
||||||
|
import type { DashSeg, Map3DSettings, MapProjectionId, PairRangeCircle } from '../types';
|
||||||
|
import { MaplibreDeckCustomLayer } from '../MaplibreDeckCustomLayer';
|
||||||
|
import {
|
||||||
|
SHIP_ICON_MAPPING,
|
||||||
|
FLAT_SHIP_ICON_SIZE,
|
||||||
|
FLAT_SHIP_ICON_SIZE_SELECTED,
|
||||||
|
FLAT_SHIP_ICON_SIZE_HIGHLIGHTED,
|
||||||
|
FLAT_LEGACY_HALO_RADIUS,
|
||||||
|
FLAT_LEGACY_HALO_RADIUS_SELECTED,
|
||||||
|
FLAT_LEGACY_HALO_RADIUS_HIGHLIGHTED,
|
||||||
|
EMPTY_MMSI_SET,
|
||||||
|
DEPTH_DISABLED_PARAMS,
|
||||||
|
GLOBE_OVERLAY_PARAMS,
|
||||||
|
HALO_OUTLINE_COLOR,
|
||||||
|
HALO_OUTLINE_COLOR_SELECTED,
|
||||||
|
HALO_OUTLINE_COLOR_HIGHLIGHTED,
|
||||||
|
PAIR_RANGE_NORMAL_DECK,
|
||||||
|
PAIR_RANGE_WARN_DECK,
|
||||||
|
PAIR_LINE_NORMAL_DECK,
|
||||||
|
PAIR_LINE_WARN_DECK,
|
||||||
|
FC_LINE_NORMAL_DECK,
|
||||||
|
FC_LINE_SUSPICIOUS_DECK,
|
||||||
|
FLEET_RANGE_LINE_DECK,
|
||||||
|
FLEET_RANGE_FILL_DECK,
|
||||||
|
PAIR_RANGE_NORMAL_DECK_HL,
|
||||||
|
PAIR_RANGE_WARN_DECK_HL,
|
||||||
|
PAIR_LINE_NORMAL_DECK_HL,
|
||||||
|
PAIR_LINE_WARN_DECK_HL,
|
||||||
|
FC_LINE_NORMAL_DECK_HL,
|
||||||
|
FC_LINE_SUSPICIOUS_DECK_HL,
|
||||||
|
FLEET_RANGE_LINE_DECK_HL,
|
||||||
|
FLEET_RANGE_FILL_DECK_HL,
|
||||||
|
} from '../constants';
|
||||||
|
import { toSafeNumber } from '../lib/setUtils';
|
||||||
|
import { getDisplayHeading, getShipColor } from '../lib/shipUtils';
|
||||||
|
import {
|
||||||
|
getShipTooltipHtml,
|
||||||
|
getPairLinkTooltipHtml,
|
||||||
|
getFcLinkTooltipHtml,
|
||||||
|
getRangeTooltipHtml,
|
||||||
|
getFleetCircleTooltipHtml,
|
||||||
|
} from '../lib/tooltips';
|
||||||
|
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(
|
||||||
|
mapRef: MutableRefObject<import('maplibre-gl').Map | null>,
|
||||||
|
overlayRef: MutableRefObject<MapboxOverlay | null>,
|
||||||
|
globeDeckLayerRef: MutableRefObject<MaplibreDeckCustomLayer | null>,
|
||||||
|
projectionBusyRef: MutableRefObject<boolean>,
|
||||||
|
opts: {
|
||||||
|
projection: MapProjectionId;
|
||||||
|
settings: Map3DSettings;
|
||||||
|
trackReplayDeckLayers: unknown[];
|
||||||
|
shipLayerData: AisTarget[];
|
||||||
|
shipOverlayLayerData: AisTarget[];
|
||||||
|
shipData: AisTarget[];
|
||||||
|
legacyHits: Map<number, LegacyVesselInfo> | null | undefined;
|
||||||
|
pairLinks: PairLink[] | undefined;
|
||||||
|
fcLinks: FcLink[] | undefined;
|
||||||
|
fcDashed: DashSeg[];
|
||||||
|
fleetCircles: FleetCircle[] | undefined;
|
||||||
|
pairRanges: PairRangeCircle[];
|
||||||
|
pairLinksInteractive: PairLink[];
|
||||||
|
pairRangesInteractive: PairRangeCircle[];
|
||||||
|
fcLinesInteractive: DashSeg[];
|
||||||
|
fleetCirclesInteractive: FleetCircle[];
|
||||||
|
overlays: MapToggleState;
|
||||||
|
shipByMmsi: Map<number, AisTarget>;
|
||||||
|
selectedMmsi: number | null;
|
||||||
|
shipHighlightSet: Set<number>;
|
||||||
|
isHighlightedFleet: (ownerKey: string, vesselMmsis: number[]) => boolean;
|
||||||
|
isHighlightedPair: (aMmsi: number, bMmsi: number) => boolean;
|
||||||
|
isHighlightedMmsi: (mmsi: number) => boolean;
|
||||||
|
clearDeckHoverPairs: () => void;
|
||||||
|
clearDeckHoverMmsi: () => void;
|
||||||
|
clearMapFleetHoverState: () => void;
|
||||||
|
setDeckHoverPairs: (next: number[]) => void;
|
||||||
|
setDeckHoverMmsi: (next: number[]) => void;
|
||||||
|
setMapFleetHoverState: (ownerKey: string | null, vesselMmsis: number[]) => void;
|
||||||
|
toFleetMmsiList: (value: unknown) => number[];
|
||||||
|
touchDeckHoverState: (isHover: boolean) => void;
|
||||||
|
hasAuxiliarySelectModifier: (ev?: { shiftKey?: boolean; ctrlKey?: boolean; metaKey?: boolean } | null) => boolean;
|
||||||
|
onDeckSelectOrHighlight: (info: unknown, allowMultiSelect?: boolean) => void;
|
||||||
|
onSelectMmsi: (mmsi: number | null) => void;
|
||||||
|
onToggleHighlightMmsi?: (mmsi: number) => void;
|
||||||
|
ensureMercatorOverlay: () => MapboxOverlay | null;
|
||||||
|
projectionRef: MutableRefObject<MapProjectionId>;
|
||||||
|
},
|
||||||
|
) {
|
||||||
|
const {
|
||||||
|
projection, settings, trackReplayDeckLayers, shipLayerData, shipOverlayLayerData, shipData,
|
||||||
|
legacyHits, pairLinks, fcDashed, fleetCircles, pairRanges,
|
||||||
|
pairLinksInteractive, pairRangesInteractive, fcLinesInteractive, fleetCirclesInteractive,
|
||||||
|
overlays, shipByMmsi, selectedMmsi, shipHighlightSet,
|
||||||
|
isHighlightedFleet, isHighlightedPair, isHighlightedMmsi,
|
||||||
|
clearDeckHoverPairs, clearDeckHoverMmsi, clearMapFleetHoverState,
|
||||||
|
setDeckHoverPairs, setDeckHoverMmsi, setMapFleetHoverState,
|
||||||
|
toFleetMmsiList, touchDeckHoverState, hasAuxiliarySelectModifier,
|
||||||
|
onDeckSelectOrHighlight, onSelectMmsi, onToggleHighlightMmsi,
|
||||||
|
ensureMercatorOverlay, projectionRef,
|
||||||
|
} = opts;
|
||||||
|
|
||||||
|
const legacyTargets = useMemo(() => {
|
||||||
|
if (!legacyHits) return [];
|
||||||
|
return shipData.filter((t) => legacyHits.has(t.mmsi));
|
||||||
|
}, [shipData, legacyHits]);
|
||||||
|
|
||||||
|
const legacyTargetsOrdered = useMemo(() => {
|
||||||
|
if (legacyTargets.length === 0) return legacyTargets;
|
||||||
|
const layer = [...legacyTargets];
|
||||||
|
layer.sort((a, b) => a.mmsi - b.mmsi);
|
||||||
|
return layer;
|
||||||
|
}, [legacyTargets]);
|
||||||
|
|
||||||
|
const legacyOverlayTargets = useMemo(() => {
|
||||||
|
if (shipHighlightSet.size === 0) return [];
|
||||||
|
return legacyTargets.filter((target) => shipHighlightSet.has(target.mmsi));
|
||||||
|
}, [legacyTargets, shipHighlightSet]);
|
||||||
|
|
||||||
|
// Mercator Deck layers
|
||||||
|
useEffect(() => {
|
||||||
|
const map = mapRef.current;
|
||||||
|
if (!map) return;
|
||||||
|
if (projection !== 'mercator' || projectionBusyRef.current) {
|
||||||
|
if (projection !== 'mercator') {
|
||||||
|
try {
|
||||||
|
if (overlayRef.current) overlayRef.current.setProps({ layers: [] } as never);
|
||||||
|
} catch {
|
||||||
|
// ignore
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const deckTarget = ensureMercatorOverlay();
|
||||||
|
if (!deckTarget) return;
|
||||||
|
|
||||||
|
const layers: unknown[] = [];
|
||||||
|
const overlayParams = DEPTH_DISABLED_PARAMS;
|
||||||
|
const clearDeckHover = () => {
|
||||||
|
touchDeckHoverState(false);
|
||||||
|
};
|
||||||
|
const isTargetShip = (mmsi: number) => (legacyHits ? legacyHits.has(mmsi) : false);
|
||||||
|
const shipOtherData: AisTarget[] = [];
|
||||||
|
const shipTargetData: AisTarget[] = [];
|
||||||
|
for (const t of shipLayerData) {
|
||||||
|
if (isTargetShip(t.mmsi)) shipTargetData.push(t);
|
||||||
|
else shipOtherData.push(t);
|
||||||
|
}
|
||||||
|
const shipOverlayOtherData: AisTarget[] = [];
|
||||||
|
const shipOverlayTargetData: AisTarget[] = [];
|
||||||
|
for (const t of shipOverlayLayerData) {
|
||||||
|
if (isTargetShip(t.mmsi)) shipOverlayTargetData.push(t);
|
||||||
|
else shipOverlayOtherData.push(t);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (settings.showDensity) {
|
||||||
|
layers.push(
|
||||||
|
new HexagonLayer<AisTarget>({
|
||||||
|
id: 'density',
|
||||||
|
data: shipLayerData,
|
||||||
|
pickable: true,
|
||||||
|
extruded: true,
|
||||||
|
radius: 2500,
|
||||||
|
elevationScale: 35,
|
||||||
|
coverage: 0.92,
|
||||||
|
opacity: 0.35,
|
||||||
|
getPosition: (d) => [d.lon, d.lat],
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (overlays.pairRange && pairRanges.length > 0) {
|
||||||
|
layers.push(
|
||||||
|
new ScatterplotLayer<PairRangeCircle>({
|
||||||
|
id: 'pair-range',
|
||||||
|
data: pairRanges,
|
||||||
|
pickable: true,
|
||||||
|
billboard: false,
|
||||||
|
parameters: overlayParams,
|
||||||
|
filled: false,
|
||||||
|
stroked: true,
|
||||||
|
radiusUnits: 'meters',
|
||||||
|
getRadius: (d) => d.radiusNm * 1852,
|
||||||
|
radiusMinPixels: 10,
|
||||||
|
lineWidthUnits: 'pixels',
|
||||||
|
getLineWidth: () => 1,
|
||||||
|
getLineColor: (d) => (d.warn ? PAIR_RANGE_WARN_DECK : PAIR_RANGE_NORMAL_DECK),
|
||||||
|
getPosition: (d) => d.center,
|
||||||
|
onHover: (info) => {
|
||||||
|
if (!info.object) { clearDeckHover(); return; }
|
||||||
|
touchDeckHoverState(true);
|
||||||
|
const p = info.object as PairRangeCircle;
|
||||||
|
setDeckHoverPairs([p.aMmsi, p.bMmsi]);
|
||||||
|
setDeckHoverMmsi([p.aMmsi, p.bMmsi]);
|
||||||
|
clearMapFleetHoverState();
|
||||||
|
},
|
||||||
|
onClick: (info) => {
|
||||||
|
if (!info.object) { onSelectMmsi(null); return; }
|
||||||
|
const obj = info.object as PairRangeCircle;
|
||||||
|
const sourceEvent = (info as { srcEvent?: { shiftKey?: boolean; ctrlKey?: boolean; metaKey?: boolean } | null }).srcEvent;
|
||||||
|
if (sourceEvent && hasAuxiliarySelectModifier(sourceEvent)) {
|
||||||
|
onToggleHighlightMmsi?.(obj.aMmsi);
|
||||||
|
onToggleHighlightMmsi?.(obj.bMmsi);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
onDeckSelectOrHighlight({ mmsi: obj.aMmsi });
|
||||||
|
},
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (overlays.pairLines && (pairLinks?.length ?? 0) > 0) {
|
||||||
|
layers.push(
|
||||||
|
new LineLayer<PairLink>({
|
||||||
|
id: 'pair-lines',
|
||||||
|
data: pairLinks,
|
||||||
|
pickable: true,
|
||||||
|
parameters: overlayParams,
|
||||||
|
getSourcePosition: (d) => d.from,
|
||||||
|
getTargetPosition: (d) => d.to,
|
||||||
|
getColor: (d) => (d.warn ? PAIR_LINE_WARN_DECK : PAIR_LINE_NORMAL_DECK),
|
||||||
|
getWidth: (d) => (d.warn ? 2.2 : 1.4),
|
||||||
|
widthUnits: 'pixels',
|
||||||
|
onHover: (info) => {
|
||||||
|
if (!info.object) { clearDeckHover(); return; }
|
||||||
|
touchDeckHoverState(true);
|
||||||
|
const obj = info.object as PairLink;
|
||||||
|
setDeckHoverPairs([obj.aMmsi, obj.bMmsi]);
|
||||||
|
setDeckHoverMmsi([obj.aMmsi, obj.bMmsi]);
|
||||||
|
clearMapFleetHoverState();
|
||||||
|
},
|
||||||
|
onClick: (info) => {
|
||||||
|
if (!info.object) return;
|
||||||
|
const obj = info.object as PairLink;
|
||||||
|
const sourceEvent = (info as { srcEvent?: { shiftKey?: boolean; ctrlKey?: boolean; metaKey?: boolean } | null }).srcEvent;
|
||||||
|
if (sourceEvent && hasAuxiliarySelectModifier(sourceEvent)) {
|
||||||
|
onToggleHighlightMmsi?.(obj.aMmsi);
|
||||||
|
onToggleHighlightMmsi?.(obj.bMmsi);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
onDeckSelectOrHighlight({ mmsi: obj.aMmsi });
|
||||||
|
},
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (overlays.fcLines && fcDashed.length > 0) {
|
||||||
|
layers.push(
|
||||||
|
new LineLayer<DashSeg>({
|
||||||
|
id: 'fc-lines',
|
||||||
|
data: fcDashed,
|
||||||
|
pickable: true,
|
||||||
|
parameters: overlayParams,
|
||||||
|
getSourcePosition: (d) => d.from,
|
||||||
|
getTargetPosition: (d) => d.to,
|
||||||
|
getColor: (d) => (d.suspicious ? FC_LINE_SUSPICIOUS_DECK : FC_LINE_NORMAL_DECK),
|
||||||
|
getWidth: () => 1.3,
|
||||||
|
widthUnits: 'pixels',
|
||||||
|
onHover: (info) => {
|
||||||
|
if (!info.object) { clearDeckHover(); return; }
|
||||||
|
touchDeckHoverState(true);
|
||||||
|
const obj = info.object as DashSeg;
|
||||||
|
if (obj.fromMmsi == null || obj.toMmsi == null) { clearDeckHover(); return; }
|
||||||
|
setDeckHoverPairs([obj.fromMmsi, obj.toMmsi]);
|
||||||
|
setDeckHoverMmsi([obj.fromMmsi, obj.toMmsi]);
|
||||||
|
clearMapFleetHoverState();
|
||||||
|
},
|
||||||
|
onClick: (info) => {
|
||||||
|
if (!info.object) return;
|
||||||
|
const obj = info.object as DashSeg;
|
||||||
|
if (obj.fromMmsi == null || obj.toMmsi == null) return;
|
||||||
|
const sourceEvent = (info as { srcEvent?: { shiftKey?: boolean; ctrlKey?: boolean; metaKey?: boolean } | null }).srcEvent;
|
||||||
|
if (sourceEvent && hasAuxiliarySelectModifier(sourceEvent)) {
|
||||||
|
onToggleHighlightMmsi?.(obj.fromMmsi);
|
||||||
|
onToggleHighlightMmsi?.(obj.toMmsi);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
onDeckSelectOrHighlight({ mmsi: obj.fromMmsi });
|
||||||
|
},
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (overlays.fleetCircles && (fleetCircles?.length ?? 0) > 0) {
|
||||||
|
layers.push(
|
||||||
|
new ScatterplotLayer<FleetCircle>({
|
||||||
|
id: 'fleet-circles',
|
||||||
|
data: fleetCircles,
|
||||||
|
pickable: true,
|
||||||
|
billboard: false,
|
||||||
|
parameters: overlayParams,
|
||||||
|
filled: false,
|
||||||
|
stroked: true,
|
||||||
|
radiusUnits: 'meters',
|
||||||
|
getRadius: (d) => d.radiusNm * 1852,
|
||||||
|
lineWidthUnits: 'pixels',
|
||||||
|
getLineWidth: () => 1.1,
|
||||||
|
getLineColor: () => FLEET_RANGE_LINE_DECK,
|
||||||
|
getPosition: (d) => d.center,
|
||||||
|
onHover: (info) => {
|
||||||
|
if (!info.object) { clearDeckHover(); return; }
|
||||||
|
touchDeckHoverState(true);
|
||||||
|
const obj = info.object as FleetCircle;
|
||||||
|
const list = toFleetMmsiList(obj.vesselMmsis);
|
||||||
|
setMapFleetHoverState(obj.ownerKey || null, list);
|
||||||
|
setDeckHoverMmsi(list);
|
||||||
|
clearDeckHoverPairs();
|
||||||
|
},
|
||||||
|
onClick: (info) => {
|
||||||
|
if (!info.object) return;
|
||||||
|
const obj = info.object as FleetCircle;
|
||||||
|
const list = toFleetMmsiList(obj.vesselMmsis);
|
||||||
|
const sourceEvent = (info as { srcEvent?: { shiftKey?: boolean; ctrlKey?: boolean; metaKey?: boolean } | null }).srcEvent;
|
||||||
|
if (sourceEvent && hasAuxiliarySelectModifier(sourceEvent)) {
|
||||||
|
for (const mmsi of list) onToggleHighlightMmsi?.(mmsi);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const first = list[0];
|
||||||
|
if (first != null) onDeckSelectOrHighlight({ mmsi: first });
|
||||||
|
},
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
layers.push(
|
||||||
|
new ScatterplotLayer<FleetCircle>({
|
||||||
|
id: 'fleet-circles-fill',
|
||||||
|
data: fleetCircles,
|
||||||
|
pickable: false,
|
||||||
|
billboard: false,
|
||||||
|
parameters: overlayParams,
|
||||||
|
filled: true,
|
||||||
|
stroked: false,
|
||||||
|
radiusUnits: 'meters',
|
||||||
|
getRadius: (d) => d.radiusNm * 1852,
|
||||||
|
getFillColor: () => FLEET_RANGE_FILL_DECK,
|
||||||
|
getPosition: (d) => d.center,
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (settings.showShips) {
|
||||||
|
const shipOnHover = (info: PickingInfo) => {
|
||||||
|
if (!info.object) { clearDeckHover(); return; }
|
||||||
|
touchDeckHoverState(true);
|
||||||
|
const obj = info.object as AisTarget;
|
||||||
|
setDeckHoverMmsi([obj.mmsi]);
|
||||||
|
clearDeckHoverPairs();
|
||||||
|
clearMapFleetHoverState();
|
||||||
|
};
|
||||||
|
const shipOnClick = (info: PickingInfo) => {
|
||||||
|
if (!info.object) { onSelectMmsi(null); return; }
|
||||||
|
onDeckSelectOrHighlight(
|
||||||
|
{
|
||||||
|
mmsi: (info.object as AisTarget).mmsi,
|
||||||
|
srcEvent: (info as { srcEvent?: { shiftKey?: boolean; ctrlKey?: boolean; metaKey?: boolean } | null }).srcEvent,
|
||||||
|
},
|
||||||
|
true,
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
if (shipOtherData.length > 0) {
|
||||||
|
layers.push(
|
||||||
|
new IconLayer<AisTarget>({
|
||||||
|
id: 'ships-other',
|
||||||
|
data: shipOtherData,
|
||||||
|
pickable: true,
|
||||||
|
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: () => FLAT_SHIP_ICON_SIZE,
|
||||||
|
getColor: (d) => getShipColor(d, null, null, EMPTY_MMSI_SET),
|
||||||
|
onHover: shipOnHover,
|
||||||
|
onClick: shipOnClick,
|
||||||
|
alphaCutoff: 0.05,
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (shipOverlayOtherData.length > 0) {
|
||||||
|
layers.push(
|
||||||
|
new IconLayer<AisTarget>({
|
||||||
|
id: 'ships-overlay-other',
|
||||||
|
data: shipOverlayOtherData,
|
||||||
|
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) => getShipColor(d, selectedMmsi, null, shipHighlightSet),
|
||||||
|
alphaCutoff: 0.05,
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (legacyTargetsOrdered.length > 0) {
|
||||||
|
layers.push(
|
||||||
|
new ScatterplotLayer<AisTarget>({
|
||||||
|
id: 'legacy-halo',
|
||||||
|
data: legacyTargetsOrdered,
|
||||||
|
pickable: false,
|
||||||
|
billboard: false,
|
||||||
|
parameters: overlayParams,
|
||||||
|
filled: false,
|
||||||
|
stroked: true,
|
||||||
|
radiusUnits: 'pixels',
|
||||||
|
getRadius: () => FLAT_LEGACY_HALO_RADIUS,
|
||||||
|
lineWidthUnits: 'pixels',
|
||||||
|
getLineWidth: () => 2,
|
||||||
|
getLineColor: () => HALO_OUTLINE_COLOR,
|
||||||
|
getPosition: (d) => [d.lon, d.lat] as [number, number],
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (shipTargetData.length > 0) {
|
||||||
|
layers.push(
|
||||||
|
new IconLayer<AisTarget>({
|
||||||
|
id: 'ships-target',
|
||||||
|
data: shipTargetData,
|
||||||
|
pickable: true,
|
||||||
|
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: () => FLAT_SHIP_ICON_SIZE,
|
||||||
|
getColor: (d) => getShipColor(d, null, legacyHits?.get(d.mmsi)?.shipCode ?? null, EMPTY_MMSI_SET),
|
||||||
|
onHover: shipOnHover,
|
||||||
|
onClick: shipOnClick,
|
||||||
|
alphaCutoff: 0.05,
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
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 }));
|
||||||
|
}
|
||||||
|
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' }));
|
||||||
|
}
|
||||||
|
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' }));
|
||||||
|
}
|
||||||
|
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', 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 }));
|
||||||
|
}
|
||||||
|
|
||||||
|
if (settings.showShips && legacyOverlayTargets.length > 0) {
|
||||||
|
layers.push(new ScatterplotLayer<AisTarget>({ id: 'legacy-halo-overlay', data: legacyOverlayTargets, pickable: false, billboard: false, parameters: overlayParams, filled: false, stroked: true, radiusUnits: 'pixels', getRadius: (d) => { if (selectedMmsi && d.mmsi === selectedMmsi) return FLAT_LEGACY_HALO_RADIUS_SELECTED; return FLAT_LEGACY_HALO_RADIUS_HIGHLIGHTED; }, lineWidthUnits: 'pixels', getLineWidth: (d) => (selectedMmsi && d.mmsi === selectedMmsi ? 2.5 : 2.2), getLineColor: (d) => { if (selectedMmsi && d.mmsi === selectedMmsi) return HALO_OUTLINE_COLOR_SELECTED; if (shipHighlightSet.has(d.mmsi)) return HALO_OUTLINE_COLOR_HIGHLIGHTED; return HALO_OUTLINE_COLOR; }, getPosition: (d) => [d.lon, d.lat] as [number, number] }));
|
||||||
|
}
|
||||||
|
|
||||||
|
if (settings.showShips && shipOverlayLayerData.filter((t) => legacyHits?.has(t.mmsi)).length > 0) {
|
||||||
|
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: 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 normalizedBaseLayers = sanitizeDeckLayerList(layers);
|
||||||
|
const normalizedTrackLayers = sanitizeDeckLayerList(trackReplayDeckLayers);
|
||||||
|
const normalizedLayers = sanitizeDeckLayerList([...normalizedBaseLayers, ...normalizedTrackLayers]);
|
||||||
|
const deckProps = {
|
||||||
|
layers: normalizedLayers,
|
||||||
|
getTooltip: (info: PickingInfo) => {
|
||||||
|
if (!info.object) return null;
|
||||||
|
if (info.layer && info.layer.id === 'density') {
|
||||||
|
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||||
|
const o: any = info.object;
|
||||||
|
const n = Array.isArray(o?.points) ? o.points.length : 0;
|
||||||
|
return { text: `AIS density: ${n}` };
|
||||||
|
}
|
||||||
|
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||||
|
const obj: any = info.object;
|
||||||
|
if (typeof obj.mmsi === 'number') {
|
||||||
|
return getShipTooltipHtml({ mmsi: obj.mmsi, targetByMmsi: shipByMmsi, legacyHits });
|
||||||
|
}
|
||||||
|
if (info.layer && info.layer.id === 'pair-lines') {
|
||||||
|
const aMmsi = toSafeNumber(obj.aMmsi);
|
||||||
|
const bMmsi = toSafeNumber(obj.bMmsi);
|
||||||
|
if (aMmsi == null || bMmsi == null) return null;
|
||||||
|
return getPairLinkTooltipHtml({ warn: !!obj.warn, distanceNm: toSafeNumber(obj.distanceNm), aMmsi, bMmsi, legacyHits, targetByMmsi: shipByMmsi });
|
||||||
|
}
|
||||||
|
if (info.layer && info.layer.id === 'fc-lines') {
|
||||||
|
const fcMmsi = toSafeNumber(obj.fcMmsi);
|
||||||
|
const otherMmsi = toSafeNumber(obj.otherMmsi);
|
||||||
|
if (fcMmsi == null || otherMmsi == null) return null;
|
||||||
|
return getFcLinkTooltipHtml({ suspicious: !!obj.suspicious, distanceNm: toSafeNumber(obj.distanceNm), fcMmsi, otherMmsi, legacyHits, targetByMmsi: shipByMmsi });
|
||||||
|
}
|
||||||
|
if (info.layer && info.layer.id === 'pair-range') {
|
||||||
|
const aMmsi = toSafeNumber(obj.aMmsi);
|
||||||
|
const bMmsi = toSafeNumber(obj.bMmsi);
|
||||||
|
if (aMmsi == null || bMmsi == null) return null;
|
||||||
|
return getRangeTooltipHtml({ warn: !!obj.warn, distanceNm: toSafeNumber(obj.distanceNm), aMmsi, bMmsi, legacyHits });
|
||||||
|
}
|
||||||
|
if (info.layer && info.layer.id === 'fleet-circles') {
|
||||||
|
return getFleetCircleTooltipHtml({ ownerKey: String(obj.ownerKey ?? ''), ownerLabel: String(obj.ownerKey ?? ''), count: Number(obj.count ?? 0) });
|
||||||
|
}
|
||||||
|
return null;
|
||||||
|
},
|
||||||
|
onClick: (info: PickingInfo) => {
|
||||||
|
if (!info.object) { onSelectMmsi(null); return; }
|
||||||
|
if (info.layer && info.layer.id === 'density') return;
|
||||||
|
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||||
|
const obj: any = info.object;
|
||||||
|
if (typeof obj.mmsi === 'number') {
|
||||||
|
const t = obj as AisTarget;
|
||||||
|
const sourceEvent = (info as { srcEvent?: { shiftKey?: boolean; ctrlKey?: boolean; metaKey?: boolean } | null }).srcEvent;
|
||||||
|
if (sourceEvent && hasAuxiliarySelectModifier(sourceEvent)) {
|
||||||
|
onToggleHighlightMmsi?.(t.mmsi);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
onSelectMmsi(t.mmsi);
|
||||||
|
const clickOpts = { center: [t.lon, t.lat] as [number, number], zoom: Math.max(map.getZoom(), 10), duration: 600 };
|
||||||
|
if (projectionRef.current === 'globe') {
|
||||||
|
map.flyTo(clickOpts);
|
||||||
|
} else {
|
||||||
|
map.easeTo(clickOpts);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
|
try {
|
||||||
|
deckTarget.setProps(deckProps as never);
|
||||||
|
} catch (e) {
|
||||||
|
console.error('Failed to apply base mercator deck props. Keeping previous layer set.', e);
|
||||||
|
}
|
||||||
|
}, [
|
||||||
|
ensureMercatorOverlay,
|
||||||
|
projection,
|
||||||
|
shipLayerData,
|
||||||
|
shipByMmsi,
|
||||||
|
pairRanges,
|
||||||
|
pairLinks,
|
||||||
|
fcDashed,
|
||||||
|
fleetCircles,
|
||||||
|
legacyTargetsOrdered,
|
||||||
|
legacyHits,
|
||||||
|
legacyOverlayTargets,
|
||||||
|
shipOverlayLayerData,
|
||||||
|
pairRangesInteractive,
|
||||||
|
pairLinksInteractive,
|
||||||
|
fcLinesInteractive,
|
||||||
|
fleetCirclesInteractive,
|
||||||
|
overlays.pairRange,
|
||||||
|
overlays.pairLines,
|
||||||
|
overlays.fcLines,
|
||||||
|
overlays.fleetCircles,
|
||||||
|
settings.showDensity,
|
||||||
|
settings.showShips,
|
||||||
|
trackReplayDeckLayers,
|
||||||
|
onDeckSelectOrHighlight,
|
||||||
|
onSelectMmsi,
|
||||||
|
onToggleHighlightMmsi,
|
||||||
|
setDeckHoverPairs,
|
||||||
|
clearMapFleetHoverState,
|
||||||
|
setDeckHoverMmsi,
|
||||||
|
clearDeckHoverMmsi,
|
||||||
|
toFleetMmsiList,
|
||||||
|
touchDeckHoverState,
|
||||||
|
hasAuxiliarySelectModifier,
|
||||||
|
]);
|
||||||
|
|
||||||
|
// Globe Deck overlay
|
||||||
|
useEffect(() => {
|
||||||
|
const map = mapRef.current;
|
||||||
|
if (!map || projection !== 'globe' || projectionBusyRef.current) return;
|
||||||
|
const deckTarget = globeDeckLayerRef.current;
|
||||||
|
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 globeLayers: unknown[] = [];
|
||||||
|
|
||||||
|
if (overlays.pairRange && pairRanges.length > 0) {
|
||||||
|
globeLayers.push(new ScatterplotLayer<PairRangeCircle>({ id: 'pair-range-globe', data: pairRanges, pickable: true, billboard: false, parameters: overlayParams, filled: false, stroked: true, radiusUnits: 'meters', getRadius: (d) => d.radiusNm * 1852, radiusMinPixels: 10, lineWidthUnits: 'pixels', getLineWidth: (d) => (isHighlightedPair(d.aMmsi, d.bMmsi) ? 2.2 : 1), getLineColor: (d) => { const hl = isHighlightedPair(d.aMmsi, d.bMmsi); if (hl) return d.warn ? PAIR_RANGE_WARN_DECK_HL : PAIR_RANGE_NORMAL_DECK_HL; return d.warn ? PAIR_RANGE_WARN_DECK : PAIR_RANGE_NORMAL_DECK; }, getPosition: (d) => d.center, onHover: (info) => { if (!info.object) { clearDeckHoverPairs(); clearDeckHoverMmsi(); return; } touchDeckHoverState(true); const p = info.object as PairRangeCircle; setDeckHoverPairs([p.aMmsi, p.bMmsi]); setDeckHoverMmsi([p.aMmsi, p.bMmsi]); clearMapFleetHoverState(); } }));
|
||||||
|
}
|
||||||
|
|
||||||
|
if (overlays.pairLines && (pairLinks?.length ?? 0) > 0) {
|
||||||
|
const links = pairLinks || [];
|
||||||
|
globeLayers.push(new LineLayer<PairLink>({ id: 'pair-lines-globe', data: links, pickable: true, parameters: overlayParams, getSourcePosition: (d) => d.from, getTargetPosition: (d) => d.to, getColor: (d) => { const hl = isHighlightedPair(d.aMmsi, d.bMmsi); if (hl) return d.warn ? PAIR_LINE_WARN_DECK_HL : PAIR_LINE_NORMAL_DECK_HL; return d.warn ? PAIR_LINE_WARN_DECK : PAIR_LINE_NORMAL_DECK; }, getWidth: (d) => (isHighlightedPair(d.aMmsi, d.bMmsi) ? 2.6 : d.warn ? 2.2 : 1.4), widthUnits: 'pixels', onHover: (info) => { if (!info.object) { clearDeckHoverPairs(); clearDeckHoverMmsi(); return; } touchDeckHoverState(true); const obj = info.object as PairLink; setDeckHoverPairs([obj.aMmsi, obj.bMmsi]); setDeckHoverMmsi([obj.aMmsi, obj.bMmsi]); clearMapFleetHoverState(); } }));
|
||||||
|
}
|
||||||
|
|
||||||
|
if (overlays.fcLines && fcDashed.length > 0) {
|
||||||
|
globeLayers.push(new LineLayer<DashSeg>({ id: 'fc-lines-globe', data: fcDashed, pickable: true, parameters: overlayParams, getSourcePosition: (d) => d.from, getTargetPosition: (d) => d.to, getColor: (d) => { const ih = [d.fromMmsi, d.toMmsi].some((v) => isHighlightedMmsi(v ?? -1)); if (ih) return d.suspicious ? FC_LINE_SUSPICIOUS_DECK_HL : FC_LINE_NORMAL_DECK_HL; return d.suspicious ? FC_LINE_SUSPICIOUS_DECK : FC_LINE_NORMAL_DECK; }, getWidth: (d) => { const ih = [d.fromMmsi, d.toMmsi].some((v) => isHighlightedMmsi(v ?? -1)); return ih ? 1.9 : 1.3; }, widthUnits: 'pixels', onHover: (info) => { if (!info.object) { clearDeckHoverPairs(); clearDeckHoverMmsi(); return; } touchDeckHoverState(true); const obj = info.object as DashSeg; if (obj.fromMmsi == null || obj.toMmsi == null) { clearDeckHoverPairs(); clearDeckHoverMmsi(); return; } setDeckHoverPairs([obj.fromMmsi, obj.toMmsi]); setDeckHoverMmsi([obj.fromMmsi, obj.toMmsi]); clearMapFleetHoverState(); } }));
|
||||||
|
}
|
||||||
|
|
||||||
|
if (overlays.fleetCircles && (fleetCircles?.length ?? 0) > 0) {
|
||||||
|
const circles = fleetCircles || [];
|
||||||
|
globeLayers.push(new ScatterplotLayer<FleetCircle>({ id: 'fleet-circles-globe', data: circles, pickable: true, billboard: false, parameters: overlayParams, filled: false, stroked: true, radiusUnits: 'meters', getRadius: (d) => d.radiusNm * 1852, lineWidthUnits: 'pixels', getLineWidth: (d) => (isHighlightedFleet(d.ownerKey, d.vesselMmsis) ? 1.8 : 1.1), getLineColor: (d) => (isHighlightedFleet(d.ownerKey, d.vesselMmsis) ? FLEET_RANGE_LINE_DECK_HL : FLEET_RANGE_LINE_DECK), getPosition: (d) => d.center, onHover: (info) => { if (!info.object) { clearDeckHoverPairs(); clearDeckHoverMmsi(); clearMapFleetHoverState(); return; } touchDeckHoverState(true); const obj = info.object as FleetCircle; const list = toFleetMmsiList(obj.vesselMmsis); setMapFleetHoverState(obj.ownerKey || null, list); setDeckHoverMmsi(list); clearDeckHoverPairs(); } }));
|
||||||
|
globeLayers.push(new ScatterplotLayer<FleetCircle>({ id: 'fleet-circles-fill-globe', data: circles, pickable: false, billboard: false, parameters: overlayParams, filled: true, stroked: false, radiusUnits: 'meters', getRadius: (d) => d.radiusNm * 1852, getFillColor: (d) => (isHighlightedFleet(d.ownerKey, d.vesselMmsis) ? FLEET_RANGE_FILL_DECK_HL : FLEET_RANGE_FILL_DECK), getPosition: (d) => d.center }));
|
||||||
|
}
|
||||||
|
|
||||||
|
if (settings.showShips && legacyTargetsOrdered.length > 0) {
|
||||||
|
globeLayers.push(new ScatterplotLayer<AisTarget>({ id: 'legacy-halo-globe', data: legacyTargetsOrdered, pickable: false, billboard: false, parameters: overlayParams, filled: false, stroked: true, radiusUnits: 'pixels', getRadius: (d) => { if (selectedMmsi && d.mmsi === selectedMmsi) return FLAT_LEGACY_HALO_RADIUS_SELECTED; return FLAT_LEGACY_HALO_RADIUS; }, lineWidthUnits: 'pixels', getLineWidth: (d) => (selectedMmsi && d.mmsi === selectedMmsi ? 2.5 : 2), getLineColor: (d) => { if (selectedMmsi && d.mmsi === selectedMmsi) return HALO_OUTLINE_COLOR_SELECTED; return HALO_OUTLINE_COLOR; }, getPosition: (d) => [d.lon, d.lat] as [number, number] }));
|
||||||
|
}
|
||||||
|
|
||||||
|
const normalizedLayers = sanitizeDeckLayerList(globeLayers);
|
||||||
|
const globeDeckProps = { layers: normalizedLayers, getTooltip: undefined, onClick: undefined };
|
||||||
|
|
||||||
|
try {
|
||||||
|
deckTarget.setProps(globeDeckProps as never);
|
||||||
|
} catch (e) {
|
||||||
|
console.error('Failed to apply globe deck props. Falling back to empty deck layer set.', e);
|
||||||
|
try {
|
||||||
|
deckTarget.setProps({ ...globeDeckProps, layers: [] as unknown[] } as never);
|
||||||
|
} catch {
|
||||||
|
// Ignore secondary failure.
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}, [
|
||||||
|
projection,
|
||||||
|
pairRanges,
|
||||||
|
pairLinks,
|
||||||
|
fcDashed,
|
||||||
|
fleetCircles,
|
||||||
|
legacyTargetsOrdered,
|
||||||
|
overlays.pairRange,
|
||||||
|
overlays.pairLines,
|
||||||
|
overlays.fcLines,
|
||||||
|
overlays.fleetCircles,
|
||||||
|
settings.showShips,
|
||||||
|
selectedMmsi,
|
||||||
|
isHighlightedFleet,
|
||||||
|
isHighlightedPair,
|
||||||
|
clearDeckHoverPairs,
|
||||||
|
clearDeckHoverMmsi,
|
||||||
|
clearMapFleetHoverState,
|
||||||
|
setDeckHoverPairs,
|
||||||
|
setDeckHoverMmsi,
|
||||||
|
setMapFleetHoverState,
|
||||||
|
toFleetMmsiList,
|
||||||
|
touchDeckHoverState,
|
||||||
|
legacyHits,
|
||||||
|
]);
|
||||||
|
}
|
||||||
61
apps/web/src/widgets/map3d/hooks/useFlyTo.ts
Normal file
61
apps/web/src/widgets/map3d/hooks/useFlyTo.ts
Normal file
@ -0,0 +1,61 @@
|
|||||||
|
import { useEffect, type MutableRefObject } from 'react';
|
||||||
|
import type maplibregl from 'maplibre-gl';
|
||||||
|
import { onMapStyleReady } from '../lib/mapCore';
|
||||||
|
import type { MapProjectionId } from '../types';
|
||||||
|
|
||||||
|
export function useFlyTo(
|
||||||
|
mapRef: MutableRefObject<maplibregl.Map | null>,
|
||||||
|
projectionRef: MutableRefObject<MapProjectionId>,
|
||||||
|
opts: {
|
||||||
|
selectedMmsi: number | null;
|
||||||
|
shipData: { mmsi: number; lon: number; lat: number }[];
|
||||||
|
fleetFocusId: string | number | undefined;
|
||||||
|
fleetFocusLon: number | undefined;
|
||||||
|
fleetFocusLat: number | undefined;
|
||||||
|
fleetFocusZoom: number | undefined;
|
||||||
|
},
|
||||||
|
) {
|
||||||
|
const { selectedMmsi, shipData, fleetFocusId, fleetFocusLon, fleetFocusLat, fleetFocusZoom } = opts;
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
const map = mapRef.current;
|
||||||
|
if (!map) return;
|
||||||
|
if (!selectedMmsi) return;
|
||||||
|
const t = shipData.find((x) => x.mmsi === selectedMmsi);
|
||||||
|
if (!t) return;
|
||||||
|
const flyOpts = { center: [t.lon, t.lat] as [number, number], zoom: Math.max(map.getZoom(), 10), duration: 600 };
|
||||||
|
if (projectionRef.current === 'globe') {
|
||||||
|
map.flyTo(flyOpts);
|
||||||
|
} else {
|
||||||
|
map.easeTo(flyOpts);
|
||||||
|
}
|
||||||
|
}, [selectedMmsi, shipData]);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
const map = mapRef.current;
|
||||||
|
if (!map || fleetFocusLon == null || fleetFocusLat == null || !Number.isFinite(fleetFocusLon) || !Number.isFinite(fleetFocusLat))
|
||||||
|
return;
|
||||||
|
const lon = fleetFocusLon;
|
||||||
|
const lat = fleetFocusLat;
|
||||||
|
const zoom = fleetFocusZoom ?? 10;
|
||||||
|
|
||||||
|
const apply = () => {
|
||||||
|
const flyOpts = { center: [lon, lat] as [number, number], zoom, duration: 700 };
|
||||||
|
if (projectionRef.current === 'globe') {
|
||||||
|
map.flyTo(flyOpts);
|
||||||
|
} else {
|
||||||
|
map.easeTo(flyOpts);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
if (map.isStyleLoaded()) {
|
||||||
|
apply();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const stop = onMapStyleReady(map, apply);
|
||||||
|
return () => {
|
||||||
|
stop();
|
||||||
|
};
|
||||||
|
}, [fleetFocusId, fleetFocusLon, fleetFocusLat, fleetFocusZoom]);
|
||||||
|
}
|
||||||
322
apps/web/src/widgets/map3d/hooks/useGlobeInteraction.ts
Normal file
322
apps/web/src/widgets/map3d/hooks/useGlobeInteraction.ts
Normal file
@ -0,0 +1,322 @@
|
|||||||
|
import { useCallback, useEffect, useRef, type MutableRefObject } from 'react';
|
||||||
|
import maplibregl from 'maplibre-gl';
|
||||||
|
import type { AisTarget } from '../../../entities/aisTarget/model/types';
|
||||||
|
import type { LegacyVesselInfo } from '../../../entities/legacyVessel/model/types';
|
||||||
|
import type { MapToggleState } from '../../../features/mapToggles/MapToggles';
|
||||||
|
import type { Map3DSettings, MapProjectionId } from '../types';
|
||||||
|
import { toIntMmsi, toSafeNumber } from '../lib/setUtils';
|
||||||
|
import {
|
||||||
|
getShipTooltipHtml,
|
||||||
|
getPairLinkTooltipHtml,
|
||||||
|
getFcLinkTooltipHtml,
|
||||||
|
getRangeTooltipHtml,
|
||||||
|
getFleetCircleTooltipHtml,
|
||||||
|
} from '../lib/tooltips';
|
||||||
|
import { getZoneIdFromProps, getZoneDisplayNameFromProps } from '../lib/zoneUtils';
|
||||||
|
|
||||||
|
export function useGlobeInteraction(
|
||||||
|
mapRef: MutableRefObject<maplibregl.Map | null>,
|
||||||
|
projectionBusyRef: MutableRefObject<boolean>,
|
||||||
|
opts: {
|
||||||
|
projection: MapProjectionId;
|
||||||
|
settings: Map3DSettings;
|
||||||
|
overlays: MapToggleState;
|
||||||
|
targets: AisTarget[];
|
||||||
|
shipData: AisTarget[];
|
||||||
|
shipByMmsi: Map<number, AisTarget>;
|
||||||
|
selectedMmsi: number | null;
|
||||||
|
hoveredZoneId: string | null;
|
||||||
|
legacyHits: Map<number, LegacyVesselInfo> | null | undefined;
|
||||||
|
clearDeckHoverPairs: () => void;
|
||||||
|
clearDeckHoverMmsi: () => void;
|
||||||
|
clearMapFleetHoverState: () => void;
|
||||||
|
setDeckHoverPairs: (next: number[]) => void;
|
||||||
|
setDeckHoverMmsi: (next: number[]) => void;
|
||||||
|
setMapFleetHoverState: (ownerKey: string | null, vesselMmsis: number[]) => void;
|
||||||
|
setHoveredZoneId: (updater: (prev: string | null) => string | null) => void;
|
||||||
|
},
|
||||||
|
) {
|
||||||
|
const {
|
||||||
|
projection, legacyHits, shipByMmsi,
|
||||||
|
clearDeckHoverPairs, clearDeckHoverMmsi, clearMapFleetHoverState,
|
||||||
|
setDeckHoverPairs, setDeckHoverMmsi, setMapFleetHoverState, setHoveredZoneId,
|
||||||
|
} = opts;
|
||||||
|
|
||||||
|
const mapTooltipRef = useRef<maplibregl.Popup | null>(null);
|
||||||
|
|
||||||
|
const clearGlobeTooltip = useCallback(() => {
|
||||||
|
if (!mapTooltipRef.current) return;
|
||||||
|
try {
|
||||||
|
mapTooltipRef.current.remove();
|
||||||
|
} catch {
|
||||||
|
// ignore
|
||||||
|
}
|
||||||
|
mapTooltipRef.current = null;
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
// eslint-disable-next-line react-hooks/preserve-manual-memoization
|
||||||
|
const setGlobeTooltip = useCallback((lngLat: maplibregl.LngLatLike, tooltipHtml: string) => {
|
||||||
|
const map = mapRef.current;
|
||||||
|
if (!map || !map.isStyleLoaded()) return;
|
||||||
|
if (!mapTooltipRef.current) {
|
||||||
|
mapTooltipRef.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;
|
||||||
|
|
||||||
|
mapTooltipRef.current.setLngLat(lngLat).setDOMContent(container).addTo(map);
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
const buildGlobeFeatureTooltip = useCallback(
|
||||||
|
(feature: { properties?: Record<string, unknown> | null; layer?: { id?: string } } | null | undefined) => {
|
||||||
|
if (!feature) return null;
|
||||||
|
const props = feature.properties || {};
|
||||||
|
const layerId = feature.layer?.id;
|
||||||
|
|
||||||
|
const maybeMmsi = toIntMmsi(props.mmsi);
|
||||||
|
if (maybeMmsi != null && maybeMmsi > 0) {
|
||||||
|
return getShipTooltipHtml({ mmsi: maybeMmsi, targetByMmsi: shipByMmsi, legacyHits });
|
||||||
|
}
|
||||||
|
|
||||||
|
if (layerId === 'pair-lines-ml') {
|
||||||
|
const warn = props.warn === true;
|
||||||
|
const aMmsi = toIntMmsi(props.aMmsi);
|
||||||
|
const bMmsi = toIntMmsi(props.bMmsi);
|
||||||
|
if (aMmsi == null || bMmsi == null) return null;
|
||||||
|
return getPairLinkTooltipHtml({
|
||||||
|
warn, distanceNm: toSafeNumber(props.distanceNm),
|
||||||
|
aMmsi, bMmsi, legacyHits, targetByMmsi: shipByMmsi,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
if (layerId === 'fc-lines-ml') {
|
||||||
|
const fcMmsi = toIntMmsi(props.fcMmsi);
|
||||||
|
const otherMmsi = toIntMmsi(props.otherMmsi);
|
||||||
|
if (fcMmsi == null || otherMmsi == null) return null;
|
||||||
|
return getFcLinkTooltipHtml({
|
||||||
|
suspicious: props.suspicious === true,
|
||||||
|
distanceNm: toSafeNumber(props.distanceNm),
|
||||||
|
fcMmsi, otherMmsi, legacyHits, targetByMmsi: shipByMmsi,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
if (layerId === 'pair-range-ml') {
|
||||||
|
const aMmsi = toIntMmsi(props.aMmsi);
|
||||||
|
const bMmsi = toIntMmsi(props.bMmsi);
|
||||||
|
if (aMmsi == null || bMmsi == null) return null;
|
||||||
|
return getRangeTooltipHtml({
|
||||||
|
warn: props.warn === true,
|
||||||
|
distanceNm: toSafeNumber(props.distanceNm),
|
||||||
|
aMmsi, bMmsi, legacyHits,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
if (layerId === 'fleet-circles-ml') {
|
||||||
|
return getFleetCircleTooltipHtml({
|
||||||
|
ownerKey: String(props.ownerKey ?? ''),
|
||||||
|
ownerLabel: String(props.ownerLabel ?? props.ownerKey ?? ''),
|
||||||
|
count: Number(props.count ?? 0),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
const zoneLabel = getZoneDisplayNameFromProps(props);
|
||||||
|
if (zoneLabel) {
|
||||||
|
return { html: `<div style="font-size: 12px; font-family: system-ui;">${zoneLabel}</div>` };
|
||||||
|
}
|
||||||
|
|
||||||
|
return null;
|
||||||
|
},
|
||||||
|
[legacyHits, shipByMmsi],
|
||||||
|
);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
const map = mapRef.current;
|
||||||
|
if (!map) return;
|
||||||
|
|
||||||
|
const clearDeckGlobeHoverState = () => {
|
||||||
|
clearDeckHoverMmsi();
|
||||||
|
clearDeckHoverPairs();
|
||||||
|
setHoveredZoneId((prev) => (prev === null ? prev : null));
|
||||||
|
clearMapFleetHoverState();
|
||||||
|
};
|
||||||
|
|
||||||
|
const resetGlobeHoverStates = () => {
|
||||||
|
clearDeckHoverMmsi();
|
||||||
|
clearDeckHoverPairs();
|
||||||
|
setHoveredZoneId((prev) => (prev === null ? prev : null));
|
||||||
|
clearMapFleetHoverState();
|
||||||
|
};
|
||||||
|
|
||||||
|
const normalizeMmsiList = (value: unknown): number[] => {
|
||||||
|
if (!Array.isArray(value)) return [];
|
||||||
|
const out: number[] = [];
|
||||||
|
for (const n of value) {
|
||||||
|
const m = toIntMmsi(n);
|
||||||
|
if (m != null) out.push(m);
|
||||||
|
}
|
||||||
|
return out;
|
||||||
|
};
|
||||||
|
|
||||||
|
const onMouseMove = (e: maplibregl.MapMouseEvent) => {
|
||||||
|
if (projection !== 'globe') {
|
||||||
|
clearGlobeTooltip();
|
||||||
|
resetGlobeHoverStates();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (projectionBusyRef.current) {
|
||||||
|
resetGlobeHoverStates();
|
||||||
|
clearGlobeTooltip();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (!map.isStyleLoaded()) {
|
||||||
|
clearDeckGlobeHoverState();
|
||||||
|
clearGlobeTooltip();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
let candidateLayerIds: string[] = [];
|
||||||
|
try {
|
||||||
|
candidateLayerIds = [
|
||||||
|
'ships-globe', 'ships-globe-lite', 'ships-globe-halo', 'ships-globe-outline',
|
||||||
|
'pair-lines-ml', 'fc-lines-ml',
|
||||||
|
'fleet-circles-ml',
|
||||||
|
'pair-range-ml',
|
||||||
|
'zones-fill', 'zones-line', 'zones-label',
|
||||||
|
].filter((id) => map.getLayer(id));
|
||||||
|
} catch {
|
||||||
|
candidateLayerIds = [];
|
||||||
|
}
|
||||||
|
|
||||||
|
if (candidateLayerIds.length === 0) {
|
||||||
|
resetGlobeHoverStates();
|
||||||
|
clearGlobeTooltip();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
let rendered: Array<{ properties?: Record<string, unknown> | null; layer?: { id?: string } }> = [];
|
||||||
|
try {
|
||||||
|
rendered = map.queryRenderedFeatures(e.point, { layers: candidateLayerIds }) as unknown as Array<{
|
||||||
|
properties?: Record<string, unknown> | null;
|
||||||
|
layer?: { id?: string };
|
||||||
|
}>;
|
||||||
|
} catch {
|
||||||
|
rendered = [];
|
||||||
|
}
|
||||||
|
|
||||||
|
const priority = [
|
||||||
|
'ships-globe', 'ships-globe-lite', 'ships-globe-halo', 'ships-globe-outline',
|
||||||
|
'pair-lines-ml', 'fc-lines-ml', 'pair-range-ml',
|
||||||
|
'fleet-circles-ml',
|
||||||
|
'zones-fill', 'zones-line', 'zones-label',
|
||||||
|
];
|
||||||
|
|
||||||
|
const first = priority.map((id) => rendered.find((r) => r.layer?.id === id)).find(Boolean) as
|
||||||
|
| { properties?: Record<string, unknown> | null; layer?: { id?: string } }
|
||||||
|
| undefined;
|
||||||
|
|
||||||
|
if (!first) {
|
||||||
|
resetGlobeHoverStates();
|
||||||
|
clearGlobeTooltip();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const layerId = first.layer?.id;
|
||||||
|
const props = first.properties || {};
|
||||||
|
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 isFcLayer = layerId === 'fc-lines-ml';
|
||||||
|
const isFleetLayer = layerId === 'fleet-circles-ml';
|
||||||
|
const isZoneLayer = layerId === 'zones-fill' || layerId === 'zones-line' || layerId === 'zones-label';
|
||||||
|
|
||||||
|
if (isShipLayer) {
|
||||||
|
const mmsi = toIntMmsi(props.mmsi);
|
||||||
|
setDeckHoverMmsi(mmsi == null ? [] : [mmsi]);
|
||||||
|
clearDeckHoverPairs();
|
||||||
|
clearMapFleetHoverState();
|
||||||
|
setHoveredZoneId((prev) => (prev === null ? prev : null));
|
||||||
|
} else if (isPairLayer) {
|
||||||
|
const aMmsi = toIntMmsi(props.aMmsi);
|
||||||
|
const bMmsi = toIntMmsi(props.bMmsi);
|
||||||
|
setDeckHoverPairs([...(aMmsi == null ? [] : [aMmsi]), ...(bMmsi == null ? [] : [bMmsi])]);
|
||||||
|
clearDeckHoverMmsi();
|
||||||
|
clearMapFleetHoverState();
|
||||||
|
setHoveredZoneId((prev) => (prev === null ? prev : null));
|
||||||
|
} else if (isFcLayer) {
|
||||||
|
const from = toIntMmsi(props.fcMmsi);
|
||||||
|
const to = toIntMmsi(props.otherMmsi);
|
||||||
|
const fromTo = [from, to].filter((v): v is number => v != null);
|
||||||
|
setDeckHoverPairs(fromTo);
|
||||||
|
setDeckHoverMmsi(fromTo);
|
||||||
|
clearMapFleetHoverState();
|
||||||
|
setHoveredZoneId((prev) => (prev === null ? prev : null));
|
||||||
|
} else if (isFleetLayer) {
|
||||||
|
const ownerKey = String(props.ownerKey ?? '');
|
||||||
|
const list = normalizeMmsiList(props.vesselMmsis);
|
||||||
|
setMapFleetHoverState(ownerKey || null, list);
|
||||||
|
clearDeckHoverMmsi();
|
||||||
|
clearDeckHoverPairs();
|
||||||
|
setHoveredZoneId((prev) => (prev === null ? prev : null));
|
||||||
|
} else if (isZoneLayer) {
|
||||||
|
clearMapFleetHoverState();
|
||||||
|
clearDeckHoverMmsi();
|
||||||
|
clearDeckHoverPairs();
|
||||||
|
const zoneId = getZoneIdFromProps(props);
|
||||||
|
setHoveredZoneId(() => zoneId || null);
|
||||||
|
} else {
|
||||||
|
resetGlobeHoverStates();
|
||||||
|
}
|
||||||
|
|
||||||
|
const tooltip = buildGlobeFeatureTooltip(first);
|
||||||
|
if (!tooltip) {
|
||||||
|
if (!isZoneLayer) {
|
||||||
|
resetGlobeHoverStates();
|
||||||
|
}
|
||||||
|
clearGlobeTooltip();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const content = tooltip?.html ?? '';
|
||||||
|
if (content) {
|
||||||
|
setGlobeTooltip(e.lngLat, content);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
clearGlobeTooltip();
|
||||||
|
};
|
||||||
|
|
||||||
|
const onMouseOut = () => {
|
||||||
|
resetGlobeHoverStates();
|
||||||
|
clearGlobeTooltip();
|
||||||
|
};
|
||||||
|
|
||||||
|
map.on('mousemove', onMouseMove);
|
||||||
|
map.on('mouseout', onMouseOut);
|
||||||
|
|
||||||
|
return () => {
|
||||||
|
map.off('mousemove', onMouseMove);
|
||||||
|
map.off('mouseout', onMouseOut);
|
||||||
|
clearGlobeTooltip();
|
||||||
|
};
|
||||||
|
}, [
|
||||||
|
projection,
|
||||||
|
buildGlobeFeatureTooltip,
|
||||||
|
clearGlobeTooltip,
|
||||||
|
clearMapFleetHoverState,
|
||||||
|
clearDeckHoverPairs,
|
||||||
|
clearDeckHoverMmsi,
|
||||||
|
setDeckHoverPairs,
|
||||||
|
setDeckHoverMmsi,
|
||||||
|
setMapFleetHoverState,
|
||||||
|
setGlobeTooltip,
|
||||||
|
]);
|
||||||
|
}
|
||||||
600
apps/web/src/widgets/map3d/hooks/useGlobeOverlays.ts
Normal file
600
apps/web/src/widgets/map3d/hooks/useGlobeOverlays.ts
Normal file
@ -0,0 +1,600 @@
|
|||||||
|
import { useCallback, useEffect, type MutableRefObject } from 'react';
|
||||||
|
import type maplibregl from 'maplibre-gl';
|
||||||
|
import type { GeoJSONSource, GeoJSONSourceSpecification, LayerSpecification } from 'maplibre-gl';
|
||||||
|
import type { FcLink, FleetCircle, PairLink } from '../../../features/legacyDashboard/model/types';
|
||||||
|
import type { MapToggleState } from '../../../features/mapToggles/MapToggles';
|
||||||
|
import type { DashSeg, MapProjectionId, PairRangeCircle } from '../types';
|
||||||
|
import {
|
||||||
|
PAIR_LINE_NORMAL_ML, PAIR_LINE_WARN_ML,
|
||||||
|
PAIR_LINE_NORMAL_ML_HL, PAIR_LINE_WARN_ML_HL,
|
||||||
|
PAIR_RANGE_NORMAL_ML, PAIR_RANGE_WARN_ML,
|
||||||
|
PAIR_RANGE_NORMAL_ML_HL, PAIR_RANGE_WARN_ML_HL,
|
||||||
|
FC_LINE_NORMAL_ML, FC_LINE_SUSPICIOUS_ML,
|
||||||
|
FC_LINE_NORMAL_ML_HL, FC_LINE_SUSPICIOUS_ML_HL,
|
||||||
|
FLEET_FILL_ML_HL,
|
||||||
|
FLEET_LINE_ML, FLEET_LINE_ML_HL,
|
||||||
|
} from '../constants';
|
||||||
|
import { makeUniqueSorted } from '../lib/setUtils';
|
||||||
|
import {
|
||||||
|
makePairLinkFeatureId,
|
||||||
|
makeFcSegmentFeatureId,
|
||||||
|
makeFleetCircleFeatureId,
|
||||||
|
} from '../lib/featureIds';
|
||||||
|
import {
|
||||||
|
makeMmsiPairHighlightExpr,
|
||||||
|
makeMmsiAnyEndpointExpr,
|
||||||
|
makeFleetOwnerMatchExpr,
|
||||||
|
makeFleetMemberMatchExpr,
|
||||||
|
} from '../lib/mlExpressions';
|
||||||
|
import { kickRepaint, onMapStyleReady } from '../lib/mapCore';
|
||||||
|
import { circleRingLngLat } from '../lib/geometry';
|
||||||
|
import { guardedSetVisibility } from '../lib/layerHelpers';
|
||||||
|
import { dashifyLine } from '../lib/dashifyLine';
|
||||||
|
|
||||||
|
export function useGlobeOverlays(
|
||||||
|
mapRef: MutableRefObject<maplibregl.Map | null>,
|
||||||
|
projectionBusyRef: MutableRefObject<boolean>,
|
||||||
|
reorderGlobeFeatureLayers: () => void,
|
||||||
|
opts: {
|
||||||
|
overlays: MapToggleState;
|
||||||
|
pairLinks: PairLink[] | undefined;
|
||||||
|
fcLinks: FcLink[] | undefined;
|
||||||
|
fleetCircles: FleetCircle[] | undefined;
|
||||||
|
projection: MapProjectionId;
|
||||||
|
mapSyncEpoch: number;
|
||||||
|
hoveredFleetMmsiList: number[];
|
||||||
|
hoveredFleetOwnerKeyList: string[];
|
||||||
|
hoveredPairMmsiList: number[];
|
||||||
|
},
|
||||||
|
) {
|
||||||
|
const {
|
||||||
|
overlays, pairLinks, fcLinks, fleetCircles, projection, mapSyncEpoch,
|
||||||
|
hoveredFleetMmsiList, hoveredFleetOwnerKeyList, hoveredPairMmsiList,
|
||||||
|
} = opts;
|
||||||
|
|
||||||
|
// Pair lines
|
||||||
|
useEffect(() => {
|
||||||
|
const map = mapRef.current;
|
||||||
|
if (!map) return;
|
||||||
|
|
||||||
|
const srcId = 'pair-lines-ml-src';
|
||||||
|
const layerId = 'pair-lines-ml';
|
||||||
|
|
||||||
|
const remove = () => {
|
||||||
|
guardedSetVisibility(map, layerId, 'none');
|
||||||
|
};
|
||||||
|
|
||||||
|
const ensure = () => {
|
||||||
|
if (projectionBusyRef.current) return;
|
||||||
|
if (!map.isStyleLoaded()) return;
|
||||||
|
const pairHoverActive = hoveredPairMmsiList.length >= 2;
|
||||||
|
if (projection !== 'globe' || (!overlays.pairLines && !pairHoverActive) || (pairLinks?.length ?? 0) === 0) {
|
||||||
|
remove();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const fc: GeoJSON.FeatureCollection<GeoJSON.LineString> = {
|
||||||
|
type: 'FeatureCollection',
|
||||||
|
features: (pairLinks || []).map((p) => ({
|
||||||
|
type: 'Feature',
|
||||||
|
id: makePairLinkFeatureId(p.aMmsi, p.bMmsi),
|
||||||
|
geometry: { type: 'LineString', coordinates: [p.from, p.to] },
|
||||||
|
properties: {
|
||||||
|
type: 'pair',
|
||||||
|
aMmsi: p.aMmsi,
|
||||||
|
bMmsi: p.bMmsi,
|
||||||
|
distanceNm: p.distanceNm,
|
||||||
|
warn: p.warn,
|
||||||
|
},
|
||||||
|
})),
|
||||||
|
};
|
||||||
|
|
||||||
|
try {
|
||||||
|
const existing = map.getSource(srcId) as GeoJSONSource | undefined;
|
||||||
|
if (existing) existing.setData(fc);
|
||||||
|
else map.addSource(srcId, { type: 'geojson', data: fc } as GeoJSONSourceSpecification);
|
||||||
|
} catch (e) {
|
||||||
|
console.warn('Pair lines source setup failed:', e);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!map.getLayer(layerId)) {
|
||||||
|
try {
|
||||||
|
map.addLayer(
|
||||||
|
{
|
||||||
|
id: layerId,
|
||||||
|
type: 'line',
|
||||||
|
source: srcId,
|
||||||
|
layout: { 'line-cap': 'round', 'line-join': 'round', visibility: 'visible' },
|
||||||
|
paint: {
|
||||||
|
'line-color': [
|
||||||
|
'case',
|
||||||
|
['==', ['get', 'highlighted'], 1],
|
||||||
|
['case', ['boolean', ['get', 'warn'], false], PAIR_LINE_WARN_ML_HL, PAIR_LINE_NORMAL_ML_HL],
|
||||||
|
['boolean', ['get', 'warn'], false],
|
||||||
|
PAIR_LINE_WARN_ML,
|
||||||
|
PAIR_LINE_NORMAL_ML,
|
||||||
|
] as never,
|
||||||
|
'line-width': [
|
||||||
|
'case',
|
||||||
|
['==', ['get', 'highlighted'], 1], 2.8,
|
||||||
|
['boolean', ['get', 'warn'], false], 2.2,
|
||||||
|
1.4,
|
||||||
|
] as never,
|
||||||
|
'line-opacity': 0.9,
|
||||||
|
},
|
||||||
|
} as unknown as LayerSpecification,
|
||||||
|
undefined,
|
||||||
|
);
|
||||||
|
} catch (e) {
|
||||||
|
console.warn('Pair lines layer add failed:', e);
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
guardedSetVisibility(map, layerId, 'visible');
|
||||||
|
}
|
||||||
|
|
||||||
|
reorderGlobeFeatureLayers();
|
||||||
|
kickRepaint(map);
|
||||||
|
};
|
||||||
|
|
||||||
|
const stop = onMapStyleReady(map, ensure);
|
||||||
|
ensure();
|
||||||
|
return () => {
|
||||||
|
stop();
|
||||||
|
};
|
||||||
|
}, [projection, overlays.pairLines, pairLinks, hoveredPairMmsiList, mapSyncEpoch, reorderGlobeFeatureLayers]);
|
||||||
|
|
||||||
|
// FC lines
|
||||||
|
useEffect(() => {
|
||||||
|
const map = mapRef.current;
|
||||||
|
if (!map) return;
|
||||||
|
|
||||||
|
const srcId = 'fc-lines-ml-src';
|
||||||
|
const layerId = 'fc-lines-ml';
|
||||||
|
|
||||||
|
const remove = () => {
|
||||||
|
guardedSetVisibility(map, layerId, 'none');
|
||||||
|
};
|
||||||
|
|
||||||
|
const ensure = () => {
|
||||||
|
if (projectionBusyRef.current) return;
|
||||||
|
if (!map.isStyleLoaded()) return;
|
||||||
|
const fleetAwarePairMmsiList = makeUniqueSorted([...hoveredPairMmsiList, ...hoveredFleetMmsiList]);
|
||||||
|
const fcHoverActive = fleetAwarePairMmsiList.length > 0;
|
||||||
|
if (projection !== 'globe' || (!overlays.fcLines && !fcHoverActive)) {
|
||||||
|
remove();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const segs: DashSeg[] = [];
|
||||||
|
for (const l of fcLinks || []) {
|
||||||
|
segs.push(...dashifyLine(l.from, l.to, l.suspicious, l.distanceNm, l.fcMmsi, l.otherMmsi));
|
||||||
|
}
|
||||||
|
if (segs.length === 0) {
|
||||||
|
remove();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const fc: GeoJSON.FeatureCollection<GeoJSON.LineString> = {
|
||||||
|
type: 'FeatureCollection',
|
||||||
|
features: segs.map((s, idx) => ({
|
||||||
|
type: 'Feature',
|
||||||
|
id: makeFcSegmentFeatureId(s.fromMmsi ?? -1, s.toMmsi ?? -1, idx),
|
||||||
|
geometry: { type: 'LineString', coordinates: [s.from, s.to] },
|
||||||
|
properties: {
|
||||||
|
type: 'fc',
|
||||||
|
suspicious: s.suspicious,
|
||||||
|
distanceNm: s.distanceNm,
|
||||||
|
fcMmsi: s.fromMmsi ?? -1,
|
||||||
|
otherMmsi: s.toMmsi ?? -1,
|
||||||
|
},
|
||||||
|
})),
|
||||||
|
};
|
||||||
|
|
||||||
|
try {
|
||||||
|
const existing = map.getSource(srcId) as GeoJSONSource | undefined;
|
||||||
|
if (existing) existing.setData(fc);
|
||||||
|
else map.addSource(srcId, { type: 'geojson', data: fc } as GeoJSONSourceSpecification);
|
||||||
|
} catch (e) {
|
||||||
|
console.warn('FC lines source setup failed:', e);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!map.getLayer(layerId)) {
|
||||||
|
try {
|
||||||
|
map.addLayer(
|
||||||
|
{
|
||||||
|
id: layerId,
|
||||||
|
type: 'line',
|
||||||
|
source: srcId,
|
||||||
|
layout: { 'line-cap': 'round', 'line-join': 'round', visibility: 'visible' },
|
||||||
|
paint: {
|
||||||
|
'line-color': [
|
||||||
|
'case',
|
||||||
|
['==', ['get', 'highlighted'], 1],
|
||||||
|
['case', ['boolean', ['get', 'suspicious'], false], FC_LINE_SUSPICIOUS_ML_HL, FC_LINE_NORMAL_ML_HL],
|
||||||
|
['boolean', ['get', 'suspicious'], false],
|
||||||
|
FC_LINE_SUSPICIOUS_ML,
|
||||||
|
FC_LINE_NORMAL_ML,
|
||||||
|
] as never,
|
||||||
|
'line-width': ['case', ['==', ['get', 'highlighted'], 1], 2.0, 1.3] as never,
|
||||||
|
'line-opacity': 0.9,
|
||||||
|
},
|
||||||
|
} as unknown as LayerSpecification,
|
||||||
|
undefined,
|
||||||
|
);
|
||||||
|
} catch (e) {
|
||||||
|
console.warn('FC lines layer add failed:', e);
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
guardedSetVisibility(map, layerId, 'visible');
|
||||||
|
}
|
||||||
|
|
||||||
|
reorderGlobeFeatureLayers();
|
||||||
|
kickRepaint(map);
|
||||||
|
};
|
||||||
|
|
||||||
|
const stop = onMapStyleReady(map, ensure);
|
||||||
|
ensure();
|
||||||
|
return () => {
|
||||||
|
stop();
|
||||||
|
};
|
||||||
|
}, [
|
||||||
|
projection,
|
||||||
|
overlays.fcLines,
|
||||||
|
fcLinks,
|
||||||
|
hoveredPairMmsiList,
|
||||||
|
hoveredFleetMmsiList,
|
||||||
|
mapSyncEpoch,
|
||||||
|
reorderGlobeFeatureLayers,
|
||||||
|
]);
|
||||||
|
|
||||||
|
// Fleet circles
|
||||||
|
useEffect(() => {
|
||||||
|
const map = mapRef.current;
|
||||||
|
if (!map) return;
|
||||||
|
|
||||||
|
const srcId = 'fleet-circles-ml-src';
|
||||||
|
const fillSrcId = 'fleet-circles-ml-fill-src';
|
||||||
|
const layerId = 'fleet-circles-ml';
|
||||||
|
const fillLayerId = 'fleet-circles-ml-fill';
|
||||||
|
|
||||||
|
// fill layer 제거됨 — globe tessellation에서 vertex 65535 초과 경고 원인
|
||||||
|
// 라인만으로 fleet circle 시각화 충분
|
||||||
|
|
||||||
|
const remove = () => {
|
||||||
|
guardedSetVisibility(map, layerId, 'none');
|
||||||
|
guardedSetVisibility(map, fillLayerId, 'none');
|
||||||
|
};
|
||||||
|
|
||||||
|
const ensure = () => {
|
||||||
|
if (projectionBusyRef.current) return;
|
||||||
|
if (!map.isStyleLoaded()) return;
|
||||||
|
const fleetHoverActive = hoveredFleetOwnerKeyList.length > 0 || hoveredFleetMmsiList.length > 0;
|
||||||
|
if (projection !== 'globe' || (!overlays.fleetCircles && !fleetHoverActive) || (fleetCircles?.length ?? 0) === 0) {
|
||||||
|
remove();
|
||||||
|
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> = {
|
||||||
|
type: 'FeatureCollection',
|
||||||
|
features: circles.map((c) => {
|
||||||
|
const ring = circleRingLngLat(c.center, c.radiusNm * 1852);
|
||||||
|
return {
|
||||||
|
type: 'Feature',
|
||||||
|
id: makeFleetCircleFeatureId(c.ownerKey),
|
||||||
|
geometry: { type: 'LineString', coordinates: ring },
|
||||||
|
properties: {
|
||||||
|
type: 'fleet',
|
||||||
|
ownerKey: c.ownerKey,
|
||||||
|
ownerLabel: c.ownerLabel,
|
||||||
|
count: c.count,
|
||||||
|
vesselMmsis: c.vesselMmsis,
|
||||||
|
highlighted: 0,
|
||||||
|
},
|
||||||
|
};
|
||||||
|
}),
|
||||||
|
};
|
||||||
|
|
||||||
|
const fcFill: GeoJSON.FeatureCollection<GeoJSON.Polygon> = {
|
||||||
|
type: 'FeatureCollection',
|
||||||
|
features: circles
|
||||||
|
.filter((c) => isHighlightedFleet(c.ownerKey, c.vesselMmsis))
|
||||||
|
.map((c) => ({
|
||||||
|
type: 'Feature',
|
||||||
|
id: makeFleetCircleFeatureId(`${c.ownerKey}-fill`),
|
||||||
|
geometry: {
|
||||||
|
type: 'Polygon',
|
||||||
|
coordinates: [circleRingLngLat(c.center, c.radiusNm * 1852, 24)],
|
||||||
|
},
|
||||||
|
properties: {
|
||||||
|
ownerKey: c.ownerKey,
|
||||||
|
},
|
||||||
|
})),
|
||||||
|
};
|
||||||
|
|
||||||
|
try {
|
||||||
|
const existing = map.getSource(srcId) as GeoJSONSource | undefined;
|
||||||
|
if (existing) existing.setData(fcLine);
|
||||||
|
else map.addSource(srcId, { type: 'geojson', data: fcLine } as GeoJSONSourceSpecification);
|
||||||
|
} catch (e) {
|
||||||
|
console.warn('Fleet circles source setup failed:', e);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
const existingFill = map.getSource(fillSrcId) as GeoJSONSource | undefined;
|
||||||
|
if (existingFill) existingFill.setData(fcFill);
|
||||||
|
else map.addSource(fillSrcId, { type: 'geojson', data: fcFill } as GeoJSONSourceSpecification);
|
||||||
|
} catch (e) {
|
||||||
|
console.warn('Fleet circles fill source setup failed:', e);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!map.getLayer(layerId)) {
|
||||||
|
try {
|
||||||
|
map.addLayer(
|
||||||
|
{
|
||||||
|
id: layerId,
|
||||||
|
type: 'line',
|
||||||
|
source: srcId,
|
||||||
|
layout: { 'line-cap': 'round', 'line-join': 'round', visibility: 'visible' },
|
||||||
|
paint: {
|
||||||
|
'line-color': ['case', ['==', ['get', 'highlighted'], 1], FLEET_LINE_ML_HL, FLEET_LINE_ML] as never,
|
||||||
|
'line-width': ['case', ['==', ['get', 'highlighted'], 1], 2, 1.1] as never,
|
||||||
|
'line-opacity': 0.85,
|
||||||
|
},
|
||||||
|
} as unknown as LayerSpecification,
|
||||||
|
undefined,
|
||||||
|
);
|
||||||
|
} catch (e) {
|
||||||
|
console.warn('Fleet circles layer add failed:', e);
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
guardedSetVisibility(map, layerId, 'visible');
|
||||||
|
}
|
||||||
|
|
||||||
|
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();
|
||||||
|
kickRepaint(map);
|
||||||
|
};
|
||||||
|
|
||||||
|
const stop = onMapStyleReady(map, ensure);
|
||||||
|
ensure();
|
||||||
|
return () => {
|
||||||
|
stop();
|
||||||
|
};
|
||||||
|
}, [
|
||||||
|
projection,
|
||||||
|
overlays.fleetCircles,
|
||||||
|
fleetCircles,
|
||||||
|
hoveredFleetOwnerKeyList,
|
||||||
|
hoveredFleetMmsiList,
|
||||||
|
mapSyncEpoch,
|
||||||
|
reorderGlobeFeatureLayers,
|
||||||
|
]);
|
||||||
|
|
||||||
|
// Pair range
|
||||||
|
useEffect(() => {
|
||||||
|
const map = mapRef.current;
|
||||||
|
if (!map) return;
|
||||||
|
|
||||||
|
const srcId = 'pair-range-ml-src';
|
||||||
|
const layerId = 'pair-range-ml';
|
||||||
|
|
||||||
|
const remove = () => {
|
||||||
|
guardedSetVisibility(map, layerId, 'none');
|
||||||
|
};
|
||||||
|
|
||||||
|
const ensure = () => {
|
||||||
|
if (projectionBusyRef.current) return;
|
||||||
|
if (!map.isStyleLoaded()) return;
|
||||||
|
const pairHoverActive = hoveredPairMmsiList.length >= 2;
|
||||||
|
if (projection !== 'globe' || (!overlays.pairRange && !pairHoverActive)) {
|
||||||
|
remove();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const ranges: PairRangeCircle[] = [];
|
||||||
|
for (const p of pairLinks || []) {
|
||||||
|
const center: [number, number] = [(p.from[0] + p.to[0]) / 2, (p.from[1] + p.to[1]) / 2];
|
||||||
|
ranges.push({
|
||||||
|
center,
|
||||||
|
radiusNm: Math.max(0.05, p.distanceNm / 2),
|
||||||
|
warn: p.warn,
|
||||||
|
aMmsi: p.aMmsi,
|
||||||
|
bMmsi: p.bMmsi,
|
||||||
|
distanceNm: p.distanceNm,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
if (ranges.length === 0) {
|
||||||
|
remove();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const fc: GeoJSON.FeatureCollection<GeoJSON.LineString> = {
|
||||||
|
type: 'FeatureCollection',
|
||||||
|
features: ranges.map((c) => {
|
||||||
|
const ring = circleRingLngLat(c.center, c.radiusNm * 1852);
|
||||||
|
return {
|
||||||
|
type: 'Feature',
|
||||||
|
id: makePairLinkFeatureId(c.aMmsi, c.bMmsi),
|
||||||
|
geometry: { type: 'LineString', coordinates: ring },
|
||||||
|
properties: {
|
||||||
|
type: 'pair-range',
|
||||||
|
warn: c.warn,
|
||||||
|
aMmsi: c.aMmsi,
|
||||||
|
bMmsi: c.bMmsi,
|
||||||
|
distanceNm: c.distanceNm,
|
||||||
|
highlighted: 0,
|
||||||
|
},
|
||||||
|
};
|
||||||
|
}),
|
||||||
|
};
|
||||||
|
|
||||||
|
try {
|
||||||
|
const existing = map.getSource(srcId) as GeoJSONSource | undefined;
|
||||||
|
if (existing) existing.setData(fc);
|
||||||
|
else map.addSource(srcId, { type: 'geojson', data: fc } as GeoJSONSourceSpecification);
|
||||||
|
} catch (e) {
|
||||||
|
console.warn('Pair range source setup failed:', e);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!map.getLayer(layerId)) {
|
||||||
|
try {
|
||||||
|
map.addLayer(
|
||||||
|
{
|
||||||
|
id: layerId,
|
||||||
|
type: 'line',
|
||||||
|
source: srcId,
|
||||||
|
layout: { 'line-cap': 'round', 'line-join': 'round', visibility: 'visible' },
|
||||||
|
paint: {
|
||||||
|
'line-color': [
|
||||||
|
'case',
|
||||||
|
['==', ['get', 'highlighted'], 1],
|
||||||
|
['case', ['boolean', ['get', 'warn'], false], PAIR_RANGE_WARN_ML_HL, PAIR_RANGE_NORMAL_ML_HL],
|
||||||
|
['boolean', ['get', 'warn'], false],
|
||||||
|
PAIR_RANGE_WARN_ML,
|
||||||
|
PAIR_RANGE_NORMAL_ML,
|
||||||
|
] as never,
|
||||||
|
'line-width': ['case', ['==', ['get', 'highlighted'], 1], 1.6, 1.0] as never,
|
||||||
|
'line-opacity': 0.85,
|
||||||
|
},
|
||||||
|
} as unknown as LayerSpecification,
|
||||||
|
undefined,
|
||||||
|
);
|
||||||
|
} catch (e) {
|
||||||
|
console.warn('Pair range layer add failed:', e);
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
guardedSetVisibility(map, layerId, 'visible');
|
||||||
|
}
|
||||||
|
|
||||||
|
kickRepaint(map);
|
||||||
|
};
|
||||||
|
|
||||||
|
const stop = onMapStyleReady(map, ensure);
|
||||||
|
ensure();
|
||||||
|
return () => {
|
||||||
|
stop();
|
||||||
|
};
|
||||||
|
}, [projection, overlays.pairRange, pairLinks, hoveredPairMmsiList, mapSyncEpoch, reorderGlobeFeatureLayers]);
|
||||||
|
|
||||||
|
// Paint state updates for hover highlights
|
||||||
|
// eslint-disable-next-line react-hooks/preserve-manual-memoization
|
||||||
|
const updateGlobeOverlayPaintStates = useCallback(() => {
|
||||||
|
if (projection !== 'globe' || projectionBusyRef.current) return;
|
||||||
|
|
||||||
|
const map = mapRef.current;
|
||||||
|
if (!map || !map.isStyleLoaded()) return;
|
||||||
|
|
||||||
|
const fleetAwarePairMmsiList = makeUniqueSorted([...hoveredPairMmsiList, ...hoveredFleetMmsiList]);
|
||||||
|
|
||||||
|
const pairHighlightExpr = hoveredPairMmsiList.length >= 2
|
||||||
|
? makeMmsiPairHighlightExpr('aMmsi', 'bMmsi', hoveredPairMmsiList)
|
||||||
|
: false;
|
||||||
|
|
||||||
|
const fcEndpointHighlightExpr = fleetAwarePairMmsiList.length > 0
|
||||||
|
? makeMmsiAnyEndpointExpr('fcMmsi', 'otherMmsi', fleetAwarePairMmsiList)
|
||||||
|
: false;
|
||||||
|
|
||||||
|
const fleetOwnerMatchExpr =
|
||||||
|
hoveredFleetOwnerKeyList.length > 0 ? makeFleetOwnerMatchExpr(hoveredFleetOwnerKeyList) : false;
|
||||||
|
const fleetMemberExpr =
|
||||||
|
hoveredFleetMmsiList.length > 0 ? makeFleetMemberMatchExpr(hoveredFleetMmsiList) : false;
|
||||||
|
const fleetHighlightExpr =
|
||||||
|
hoveredFleetOwnerKeyList.length > 0 || hoveredFleetMmsiList.length > 0
|
||||||
|
? (['any', fleetOwnerMatchExpr, fleetMemberExpr] as never)
|
||||||
|
: false;
|
||||||
|
|
||||||
|
try {
|
||||||
|
if (map.getLayer('pair-lines-ml')) {
|
||||||
|
map.setPaintProperty(
|
||||||
|
'pair-lines-ml', 'line-color',
|
||||||
|
['case', pairHighlightExpr, ['case', ['boolean', ['get', 'warn'], false], PAIR_LINE_WARN_ML_HL, PAIR_LINE_NORMAL_ML_HL], ['boolean', ['get', 'warn'], false], PAIR_LINE_WARN_ML, PAIR_LINE_NORMAL_ML] as never,
|
||||||
|
);
|
||||||
|
map.setPaintProperty(
|
||||||
|
'pair-lines-ml', 'line-width',
|
||||||
|
['case', pairHighlightExpr, 2.8, ['boolean', ['get', 'warn'], false], 2.2, 1.4] as never,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
} catch {
|
||||||
|
// ignore
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
if (map.getLayer('fc-lines-ml')) {
|
||||||
|
map.setPaintProperty(
|
||||||
|
'fc-lines-ml', 'line-color',
|
||||||
|
['case', fcEndpointHighlightExpr, ['case', ['boolean', ['get', 'suspicious'], false], FC_LINE_SUSPICIOUS_ML_HL, FC_LINE_NORMAL_ML_HL], ['boolean', ['get', 'suspicious'], false], FC_LINE_SUSPICIOUS_ML, FC_LINE_NORMAL_ML] as never,
|
||||||
|
);
|
||||||
|
map.setPaintProperty(
|
||||||
|
'fc-lines-ml', 'line-width',
|
||||||
|
['case', fcEndpointHighlightExpr, 2.0, 1.3] as never,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
} catch {
|
||||||
|
// ignore
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
if (map.getLayer('pair-range-ml')) {
|
||||||
|
map.setPaintProperty(
|
||||||
|
'pair-range-ml', 'line-color',
|
||||||
|
['case', pairHighlightExpr, ['case', ['boolean', ['get', 'warn'], false], PAIR_RANGE_WARN_ML_HL, PAIR_RANGE_NORMAL_ML_HL], ['boolean', ['get', 'warn'], false], PAIR_RANGE_WARN_ML, PAIR_RANGE_NORMAL_ML] as never,
|
||||||
|
);
|
||||||
|
map.setPaintProperty(
|
||||||
|
'pair-range-ml', 'line-width',
|
||||||
|
['case', pairHighlightExpr, 1.6, 1.0] as never,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
} catch {
|
||||||
|
// ignore
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
// fleet-circles-ml-fill 제거됨 (vertex 65535 경고 원인)
|
||||||
|
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-width', ['case', fleetHighlightExpr, 2, 1.1] as never);
|
||||||
|
}
|
||||||
|
} catch {
|
||||||
|
// ignore
|
||||||
|
}
|
||||||
|
}, [projection, hoveredFleetMmsiList, hoveredFleetOwnerKeyList, hoveredPairMmsiList]);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
const map = mapRef.current;
|
||||||
|
if (!map) return;
|
||||||
|
const stop = onMapStyleReady(map, updateGlobeOverlayPaintStates);
|
||||||
|
updateGlobeOverlayPaintStates();
|
||||||
|
return () => {
|
||||||
|
stop();
|
||||||
|
};
|
||||||
|
}, [mapSyncEpoch, hoveredFleetMmsiList, hoveredFleetOwnerKeyList, hoveredPairMmsiList, projection, updateGlobeOverlayPaintStates]);
|
||||||
|
}
|
||||||
977
apps/web/src/widgets/map3d/hooks/useGlobeShips.ts
Normal file
977
apps/web/src/widgets/map3d/hooks/useGlobeShips.ts
Normal file
@ -0,0 +1,977 @@
|
|||||||
|
import { useEffect, useMemo, useRef, type MutableRefObject } from 'react';
|
||||||
|
import type maplibregl from 'maplibre-gl';
|
||||||
|
import type { GeoJSONSource, GeoJSONSourceSpecification, LayerSpecification } from 'maplibre-gl';
|
||||||
|
import type { AisTarget } from '../../../entities/aisTarget/model/types';
|
||||||
|
import type { LegacyVesselInfo } from '../../../entities/legacyVessel/model/types';
|
||||||
|
import type { MapToggleState } from '../../../features/mapToggles/MapToggles';
|
||||||
|
import type { Map3DSettings, MapProjectionId } from '../types';
|
||||||
|
import {
|
||||||
|
ANCHORED_SHIP_ICON_ID,
|
||||||
|
GLOBE_ICON_HEADING_OFFSET_DEG,
|
||||||
|
GLOBE_OUTLINE_PERMITTED,
|
||||||
|
GLOBE_OUTLINE_OTHER,
|
||||||
|
DEG2RAD,
|
||||||
|
} from '../constants';
|
||||||
|
import { isFiniteNumber } from '../lib/setUtils';
|
||||||
|
import { GLOBE_SHIP_CIRCLE_RADIUS_EXPR } from '../lib/mlExpressions';
|
||||||
|
import { kickRepaint, onMapStyleReady } from '../lib/mapCore';
|
||||||
|
import {
|
||||||
|
isAnchoredShip,
|
||||||
|
getDisplayHeading,
|
||||||
|
getGlobeBaseShipColor,
|
||||||
|
} from '../lib/shipUtils';
|
||||||
|
import {
|
||||||
|
buildFallbackGlobeAnchoredShipIcon,
|
||||||
|
ensureFallbackShipImage,
|
||||||
|
} from '../lib/globeShipIcon';
|
||||||
|
import { clampNumber } from '../lib/geometry';
|
||||||
|
import { guardedSetVisibility } from '../lib/layerHelpers';
|
||||||
|
|
||||||
|
export function useGlobeShips(
|
||||||
|
mapRef: MutableRefObject<maplibregl.Map | null>,
|
||||||
|
projectionBusyRef: MutableRefObject<boolean>,
|
||||||
|
reorderGlobeFeatureLayers: () => void,
|
||||||
|
opts: {
|
||||||
|
projection: MapProjectionId;
|
||||||
|
settings: Map3DSettings;
|
||||||
|
shipData: AisTarget[];
|
||||||
|
shipHighlightSet: Set<number>;
|
||||||
|
shipHoverOverlaySet: Set<number>;
|
||||||
|
shipOverlayLayerData: AisTarget[];
|
||||||
|
shipLayerData: AisTarget[];
|
||||||
|
shipByMmsi: Map<number, AisTarget>;
|
||||||
|
mapSyncEpoch: number;
|
||||||
|
onSelectMmsi: (mmsi: number | null) => void;
|
||||||
|
onToggleHighlightMmsi?: (mmsi: number) => void;
|
||||||
|
targets: AisTarget[];
|
||||||
|
overlays: MapToggleState;
|
||||||
|
legacyHits: Map<number, LegacyVesselInfo> | null | undefined;
|
||||||
|
selectedMmsi: number | null;
|
||||||
|
isBaseHighlightedMmsi: (mmsi: number) => boolean;
|
||||||
|
hasAuxiliarySelectModifier: (ev?: { shiftKey?: boolean; ctrlKey?: boolean; metaKey?: boolean } | null) => boolean;
|
||||||
|
onGlobeShipsReady?: (ready: boolean) => void;
|
||||||
|
},
|
||||||
|
) {
|
||||||
|
const {
|
||||||
|
projection, settings, shipData, shipHighlightSet, shipHoverOverlaySet,
|
||||||
|
shipLayerData, mapSyncEpoch, onSelectMmsi, onToggleHighlightMmsi, targets,
|
||||||
|
overlays, legacyHits, selectedMmsi, isBaseHighlightedMmsi, hasAuxiliarySelectModifier,
|
||||||
|
onGlobeShipsReady,
|
||||||
|
} = opts;
|
||||||
|
|
||||||
|
const globeShipsEpochRef = useRef(-1);
|
||||||
|
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
|
||||||
|
useEffect(() => {
|
||||||
|
const map = mapRef.current;
|
||||||
|
if (!map) return;
|
||||||
|
|
||||||
|
const srcId = 'ship-labels-src';
|
||||||
|
const layerId = 'ship-labels';
|
||||||
|
|
||||||
|
const remove = () => {
|
||||||
|
try {
|
||||||
|
if (map.getLayer(layerId)) map.removeLayer(layerId);
|
||||||
|
} catch {
|
||||||
|
// ignore
|
||||||
|
}
|
||||||
|
try {
|
||||||
|
if (map.getSource(srcId)) map.removeSource(srcId);
|
||||||
|
} catch {
|
||||||
|
// ignore
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const ensure = () => {
|
||||||
|
if (projectionBusyRef.current) return;
|
||||||
|
if (!map.isStyleLoaded()) return;
|
||||||
|
|
||||||
|
if (projection !== 'mercator' || !settings.showShips) {
|
||||||
|
remove();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const visibility = overlays.shipLabels ? 'visible' : 'none';
|
||||||
|
|
||||||
|
const features: GeoJSON.Feature<GeoJSON.Point>[] = [];
|
||||||
|
for (const t of shipData) {
|
||||||
|
const legacy = legacyHits?.get(t.mmsi) ?? null;
|
||||||
|
const isTarget = !!legacy;
|
||||||
|
const isSelected = selectedMmsi != null && t.mmsi === selectedMmsi;
|
||||||
|
const isPinnedHighlight = shipHighlightSet.has(t.mmsi);
|
||||||
|
if (!isTarget && !isSelected && !isPinnedHighlight) continue;
|
||||||
|
|
||||||
|
const labelName = (legacy?.shipNameCn || legacy?.shipNameRoman || t.name || '').trim();
|
||||||
|
if (!labelName) continue;
|
||||||
|
|
||||||
|
features.push({
|
||||||
|
type: 'Feature',
|
||||||
|
id: `ship-label-${t.mmsi}`,
|
||||||
|
geometry: { type: 'Point', coordinates: [t.lon, t.lat] },
|
||||||
|
properties: {
|
||||||
|
mmsi: t.mmsi,
|
||||||
|
labelName,
|
||||||
|
selected: isSelected ? 1 : 0,
|
||||||
|
highlighted: isPinnedHighlight ? 1 : 0,
|
||||||
|
permitted: isTarget ? 1 : 0,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
const fc: GeoJSON.FeatureCollection<GeoJSON.Point> = { type: 'FeatureCollection', features };
|
||||||
|
|
||||||
|
try {
|
||||||
|
const existing = map.getSource(srcId) as GeoJSONSource | undefined;
|
||||||
|
if (existing) existing.setData(fc);
|
||||||
|
else map.addSource(srcId, { type: 'geojson', data: fc } as GeoJSONSourceSpecification);
|
||||||
|
} catch (e) {
|
||||||
|
console.warn('Ship label source setup failed:', e);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const filter = ['!=', ['to-string', ['coalesce', ['get', 'labelName'], '']], ''] as unknown as unknown[];
|
||||||
|
|
||||||
|
if (!map.getLayer(layerId)) {
|
||||||
|
try {
|
||||||
|
map.addLayer(
|
||||||
|
{
|
||||||
|
id: layerId,
|
||||||
|
type: 'symbol',
|
||||||
|
source: srcId,
|
||||||
|
minzoom: 7,
|
||||||
|
filter: filter as never,
|
||||||
|
layout: {
|
||||||
|
visibility,
|
||||||
|
'symbol-placement': 'point',
|
||||||
|
'text-field': ['get', 'labelName'] as never,
|
||||||
|
'text-font': ['Noto Sans Regular', 'Open Sans Regular'],
|
||||||
|
'text-size': ['interpolate', ['linear'], ['zoom'], 7, 10, 10, 11, 12, 12, 14, 13] as never,
|
||||||
|
'text-anchor': 'top',
|
||||||
|
'text-offset': [0, 1.1],
|
||||||
|
'text-padding': 2,
|
||||||
|
'text-allow-overlap': false,
|
||||||
|
'text-ignore-placement': false,
|
||||||
|
},
|
||||||
|
paint: {
|
||||||
|
'text-color': [
|
||||||
|
'case',
|
||||||
|
['==', ['get', 'selected'], 1],
|
||||||
|
'rgba(14,234,255,0.95)',
|
||||||
|
['==', ['get', 'highlighted'], 1],
|
||||||
|
'rgba(245,158,11,0.95)',
|
||||||
|
'rgba(226,232,240,0.92)',
|
||||||
|
] as never,
|
||||||
|
'text-halo-color': 'rgba(2,6,23,0.85)',
|
||||||
|
'text-halo-width': 1.2,
|
||||||
|
'text-halo-blur': 0.8,
|
||||||
|
},
|
||||||
|
} as unknown as LayerSpecification,
|
||||||
|
undefined,
|
||||||
|
);
|
||||||
|
} catch (e) {
|
||||||
|
console.warn('Ship label layer add failed:', e);
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
try {
|
||||||
|
map.setLayoutProperty(layerId, 'visibility', visibility);
|
||||||
|
} catch {
|
||||||
|
// ignore
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
kickRepaint(map);
|
||||||
|
};
|
||||||
|
|
||||||
|
const stop = onMapStyleReady(map, ensure);
|
||||||
|
return () => {
|
||||||
|
stop();
|
||||||
|
};
|
||||||
|
}, [
|
||||||
|
projection,
|
||||||
|
settings.showShips,
|
||||||
|
overlays.shipLabels,
|
||||||
|
shipData,
|
||||||
|
legacyHits,
|
||||||
|
selectedMmsi,
|
||||||
|
shipHighlightSet,
|
||||||
|
mapSyncEpoch,
|
||||||
|
]);
|
||||||
|
|
||||||
|
// Ships in globe mode
|
||||||
|
useEffect(() => {
|
||||||
|
const map = mapRef.current;
|
||||||
|
if (!map) return;
|
||||||
|
|
||||||
|
const imgId = 'ship-globe-icon';
|
||||||
|
const anchoredImgId = ANCHORED_SHIP_ICON_ID;
|
||||||
|
const srcId = 'ships-globe-src';
|
||||||
|
const haloId = 'ships-globe-halo';
|
||||||
|
const outlineId = 'ships-globe-outline';
|
||||||
|
const symbolLiteId = 'ships-globe-lite';
|
||||||
|
const symbolId = 'ships-globe';
|
||||||
|
const labelId = 'ships-globe-label';
|
||||||
|
|
||||||
|
// 레이어를 제거하지 않고 visibility만 'none'으로 설정
|
||||||
|
// guardedSetVisibility로 현재 값과 동일하면 호출 생략 (style._changed 방지)
|
||||||
|
const hide = () => {
|
||||||
|
for (const id of [labelId, symbolId, symbolLiteId, outlineId, haloId]) {
|
||||||
|
guardedSetVisibility(map, id, 'none');
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const ensureImage = () => {
|
||||||
|
ensureFallbackShipImage(map, imgId);
|
||||||
|
ensureFallbackShipImage(map, anchoredImgId, buildFallbackGlobeAnchoredShipIcon);
|
||||||
|
if (map.hasImage(imgId) && map.hasImage(anchoredImgId)) return;
|
||||||
|
kickRepaint(map);
|
||||||
|
};
|
||||||
|
|
||||||
|
const ensure = () => {
|
||||||
|
if (!settings.showShips) {
|
||||||
|
hide();
|
||||||
|
onGlobeShipsReady?.(false);
|
||||||
|
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) {
|
||||||
|
globeShipsEpochRef.current = mapSyncEpoch;
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
ensureImage();
|
||||||
|
} catch (e) {
|
||||||
|
console.warn('Ship icon image setup failed:', e);
|
||||||
|
}
|
||||||
|
|
||||||
|
// 사전 계산된 GeoJSON 사용 (useMemo에서 projection과 무관하게 항상 준비됨)
|
||||||
|
const geojson = globeShipGeoJson;
|
||||||
|
|
||||||
|
try {
|
||||||
|
const existing = map.getSource(srcId) as GeoJSONSource | undefined;
|
||||||
|
if (existing) existing.setData(geojson);
|
||||||
|
else map.addSource(srcId, { type: 'geojson', data: geojson } as GeoJSONSourceSpecification);
|
||||||
|
} catch (e) {
|
||||||
|
console.warn('Ship source setup failed:', e);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
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)) {
|
||||||
|
try {
|
||||||
|
map.addLayer(
|
||||||
|
{
|
||||||
|
id: haloId,
|
||||||
|
type: 'circle',
|
||||||
|
source: srcId,
|
||||||
|
layout: {
|
||||||
|
visibility,
|
||||||
|
'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,
|
||||||
|
},
|
||||||
|
paint: {
|
||||||
|
'circle-radius': GLOBE_SHIP_CIRCLE_RADIUS_EXPR,
|
||||||
|
'circle-color': ['coalesce', ['get', 'shipColor'], '#64748b'] as never,
|
||||||
|
'circle-opacity': [
|
||||||
|
'case',
|
||||||
|
['==', ['get', 'selected'], 1], 0.38,
|
||||||
|
['==', ['get', 'highlighted'], 1], 0.34,
|
||||||
|
0.16,
|
||||||
|
] as never,
|
||||||
|
},
|
||||||
|
} as unknown as LayerSpecification,
|
||||||
|
before,
|
||||||
|
);
|
||||||
|
} catch (e) {
|
||||||
|
console.warn('Ship halo layer add failed:', e);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
// halo: data-driven expressions are static — visibility handled by fast toggle above
|
||||||
|
|
||||||
|
if (!map.getLayer(outlineId)) {
|
||||||
|
try {
|
||||||
|
map.addLayer(
|
||||||
|
{
|
||||||
|
id: outlineId,
|
||||||
|
type: 'circle',
|
||||||
|
source: srcId,
|
||||||
|
paint: {
|
||||||
|
'circle-radius': GLOBE_SHIP_CIRCLE_RADIUS_EXPR,
|
||||||
|
'circle-color': 'rgba(0,0,0,0)',
|
||||||
|
'circle-stroke-color': [
|
||||||
|
'case',
|
||||||
|
['==', ['get', 'selected'], 1], 'rgba(14,234,255,0.95)',
|
||||||
|
['==', ['get', 'highlighted'], 1], 'rgba(245,158,11,0.95)',
|
||||||
|
['==', ['get', 'permitted'], 1], GLOBE_OUTLINE_PERMITTED,
|
||||||
|
GLOBE_OUTLINE_OTHER,
|
||||||
|
] as never,
|
||||||
|
'circle-stroke-width': [
|
||||||
|
'case',
|
||||||
|
['==', ['get', 'selected'], 1], 3.4,
|
||||||
|
['==', ['get', 'highlighted'], 1], 2.7,
|
||||||
|
['==', ['get', 'permitted'], 1], 1.8,
|
||||||
|
0.7,
|
||||||
|
] as never,
|
||||||
|
'circle-stroke-opacity': 0.85,
|
||||||
|
},
|
||||||
|
layout: {
|
||||||
|
visibility,
|
||||||
|
'circle-sort-key': [
|
||||||
|
'case',
|
||||||
|
['all', ['==', ['get', 'selected'], 1], ['==', ['get', 'permitted'], 1]], 130,
|
||||||
|
['all', ['==', ['get', 'highlighted'], 1], ['==', ['get', 'permitted'], 1]], 125,
|
||||||
|
['==', ['get', 'permitted'], 1], 120,
|
||||||
|
['==', ['get', 'selected'], 1], 70,
|
||||||
|
['==', ['get', 'highlighted'], 1], 65,
|
||||||
|
30,
|
||||||
|
] as never,
|
||||||
|
},
|
||||||
|
} as unknown as LayerSpecification,
|
||||||
|
before,
|
||||||
|
);
|
||||||
|
} catch (e) {
|
||||||
|
console.warn('Ship outline layer add failed:', e);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
// outline: data-driven expressions are static — visibility handled by fast toggle
|
||||||
|
|
||||||
|
if (!map.getLayer(symbolLiteId)) {
|
||||||
|
try {
|
||||||
|
map.addLayer(
|
||||||
|
{
|
||||||
|
id: symbolLiteId,
|
||||||
|
type: 'symbol',
|
||||||
|
source: srcId,
|
||||||
|
minzoom: 6.5,
|
||||||
|
filter: nonPriorityFilter as never,
|
||||||
|
layout: {
|
||||||
|
visibility,
|
||||||
|
'symbol-sort-key': 40 as never,
|
||||||
|
'icon-image': [
|
||||||
|
'case',
|
||||||
|
['==', ['to-number', ['get', 'isAnchored'], 0], 1],
|
||||||
|
anchoredImgId,
|
||||||
|
imgId,
|
||||||
|
] as never,
|
||||||
|
'icon-size': [
|
||||||
|
'interpolate',
|
||||||
|
['linear'],
|
||||||
|
['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',
|
||||||
|
['==', ['to-number', ['get', 'isAnchored'], 0], 1],
|
||||||
|
0,
|
||||||
|
['to-number', ['get', 'heading'], 0],
|
||||||
|
] as never,
|
||||||
|
'icon-rotation-alignment': 'map',
|
||||||
|
'icon-pitch-alignment': 'map',
|
||||||
|
},
|
||||||
|
paint: {
|
||||||
|
'icon-color': ['coalesce', ['get', 'shipColor'], '#64748b'] as never,
|
||||||
|
'icon-opacity': [
|
||||||
|
'interpolate',
|
||||||
|
['linear'],
|
||||||
|
['zoom'],
|
||||||
|
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)) {
|
||||||
|
try {
|
||||||
|
map.addLayer(
|
||||||
|
{
|
||||||
|
id: symbolId,
|
||||||
|
type: 'symbol',
|
||||||
|
source: srcId,
|
||||||
|
filter: priorityFilter as never,
|
||||||
|
layout: {
|
||||||
|
visibility,
|
||||||
|
'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,
|
||||||
|
'icon-image': [
|
||||||
|
'case',
|
||||||
|
['==', ['to-number', ['get', 'isAnchored'], 0], 1],
|
||||||
|
anchoredImgId,
|
||||||
|
imgId,
|
||||||
|
] as never,
|
||||||
|
'icon-size': [
|
||||||
|
'interpolate', ['linear'], ['zoom'],
|
||||||
|
3, ['to-number', ['get', 'iconSize3'], 0.35],
|
||||||
|
7, ['to-number', ['get', 'iconSize7'], 0.45],
|
||||||
|
10, ['to-number', ['get', 'iconSize10'], 0.58],
|
||||||
|
14, ['to-number', ['get', 'iconSize14'], 0.85],
|
||||||
|
18, ['to-number', ['get', 'iconSize18'], 2.5],
|
||||||
|
] as unknown as number[],
|
||||||
|
'icon-allow-overlap': true,
|
||||||
|
'icon-ignore-placement': true,
|
||||||
|
'icon-anchor': 'center',
|
||||||
|
'icon-rotate': [
|
||||||
|
'case',
|
||||||
|
['==', ['to-number', ['get', 'isAnchored'], 0], 1], 0,
|
||||||
|
['to-number', ['get', 'heading'], 0],
|
||||||
|
] as never,
|
||||||
|
'icon-rotation-alignment': 'map',
|
||||||
|
'icon-pitch-alignment': 'map',
|
||||||
|
},
|
||||||
|
paint: {
|
||||||
|
'icon-color': ['coalesce', ['get', 'shipColor'], '#64748b'] as never,
|
||||||
|
'icon-opacity': [
|
||||||
|
'case',
|
||||||
|
['==', ['get', 'selected'], 1], 1,
|
||||||
|
['==', ['get', 'highlighted'], 1], 0.95,
|
||||||
|
['==', ['get', 'permitted'], 1], 0.93,
|
||||||
|
0.9,
|
||||||
|
] as never,
|
||||||
|
},
|
||||||
|
} as unknown as LayerSpecification,
|
||||||
|
before,
|
||||||
|
);
|
||||||
|
} catch (e) {
|
||||||
|
console.warn('Ship symbol layer add failed:', e);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
// symbol: data-driven expressions are static — visibility handled by fast toggle
|
||||||
|
|
||||||
|
const labelFilter = [
|
||||||
|
'all',
|
||||||
|
['!=', ['to-string', ['coalesce', ['get', 'labelName'], '']], ''],
|
||||||
|
[
|
||||||
|
'any',
|
||||||
|
['==', ['get', 'permitted'], 1],
|
||||||
|
['==', ['get', 'selected'], 1],
|
||||||
|
['==', ['get', 'highlighted'], 1],
|
||||||
|
],
|
||||||
|
] as unknown as unknown[];
|
||||||
|
|
||||||
|
if (!map.getLayer(labelId)) {
|
||||||
|
try {
|
||||||
|
map.addLayer(
|
||||||
|
{
|
||||||
|
id: labelId,
|
||||||
|
type: 'symbol',
|
||||||
|
source: srcId,
|
||||||
|
minzoom: 7,
|
||||||
|
filter: labelFilter as never,
|
||||||
|
layout: {
|
||||||
|
visibility: labelVisibility,
|
||||||
|
'symbol-placement': 'point',
|
||||||
|
'text-field': ['get', 'labelName'] as never,
|
||||||
|
'text-font': ['Noto Sans Regular', 'Open Sans Regular'],
|
||||||
|
'text-size': ['interpolate', ['linear'], ['zoom'], 7, 10, 10, 11, 12, 12, 14, 13] as never,
|
||||||
|
'text-anchor': 'top',
|
||||||
|
'text-offset': [0, 1.1],
|
||||||
|
'text-padding': 2,
|
||||||
|
'text-allow-overlap': false,
|
||||||
|
'text-ignore-placement': false,
|
||||||
|
},
|
||||||
|
paint: {
|
||||||
|
'text-color': [
|
||||||
|
'case',
|
||||||
|
['==', ['get', 'selected'], 1], 'rgba(14,234,255,0.95)',
|
||||||
|
['==', ['get', 'highlighted'], 1], 'rgba(245,158,11,0.95)',
|
||||||
|
'rgba(226,232,240,0.92)',
|
||||||
|
] as never,
|
||||||
|
'text-halo-color': 'rgba(2,6,23,0.85)',
|
||||||
|
'text-halo-width': 1.2,
|
||||||
|
'text-halo-blur': 0.8,
|
||||||
|
},
|
||||||
|
} as unknown as LayerSpecification,
|
||||||
|
undefined,
|
||||||
|
);
|
||||||
|
} catch (e) {
|
||||||
|
console.warn('Ship label layer add failed:', e);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
// label: filter/text-field are static — visibility handled by fast toggle
|
||||||
|
|
||||||
|
// 레이어가 준비되었음을 알림 (projection과 무관 — 토글 버튼 활성화용)
|
||||||
|
onGlobeShipsReady?.(true);
|
||||||
|
if (projection === 'globe') {
|
||||||
|
reorderGlobeFeatureLayers();
|
||||||
|
}
|
||||||
|
kickRepaint(map);
|
||||||
|
};
|
||||||
|
|
||||||
|
const stop = onMapStyleReady(map, ensure);
|
||||||
|
return () => {
|
||||||
|
stop();
|
||||||
|
};
|
||||||
|
}, [
|
||||||
|
projection,
|
||||||
|
settings.showShips,
|
||||||
|
overlays.shipLabels,
|
||||||
|
globeShipGeoJson,
|
||||||
|
selectedMmsi,
|
||||||
|
isBaseHighlightedMmsi,
|
||||||
|
mapSyncEpoch,
|
||||||
|
reorderGlobeFeatureLayers,
|
||||||
|
onGlobeShipsReady,
|
||||||
|
]);
|
||||||
|
|
||||||
|
// Globe hover overlay ships
|
||||||
|
useEffect(() => {
|
||||||
|
const map = mapRef.current;
|
||||||
|
if (!map) return;
|
||||||
|
|
||||||
|
const imgId = 'ship-globe-icon';
|
||||||
|
const srcId = 'ships-globe-hover-src';
|
||||||
|
const haloId = 'ships-globe-hover-halo';
|
||||||
|
const outlineId = 'ships-globe-hover-outline';
|
||||||
|
const symbolId = 'ships-globe-hover';
|
||||||
|
|
||||||
|
const hideHover = () => {
|
||||||
|
for (const id of [symbolId, outlineId, haloId]) {
|
||||||
|
guardedSetVisibility(map, id, 'none');
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const ensure = () => {
|
||||||
|
if (projectionBusyRef.current) return;
|
||||||
|
if (!map.isStyleLoaded()) return;
|
||||||
|
|
||||||
|
if (projection !== 'globe' || !settings.showShips || shipHoverOverlaySet.size === 0) {
|
||||||
|
hideHover();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (globeShipsEpochRef.current !== mapSyncEpoch) {
|
||||||
|
globeShipsEpochRef.current = mapSyncEpoch;
|
||||||
|
}
|
||||||
|
|
||||||
|
ensureFallbackShipImage(map, imgId);
|
||||||
|
if (!map.hasImage(imgId)) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const hovered = shipLayerData.filter((t) => shipHoverOverlaySet.has(t.mmsi) && !!legacyHits?.has(t.mmsi));
|
||||||
|
if (hovered.length === 0) {
|
||||||
|
hideHover();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const hoverSignature = hovered
|
||||||
|
.map((t) => `${t.mmsi}:${t.lon.toFixed(6)}:${t.lat.toFixed(6)}:${t.heading ?? 0}`)
|
||||||
|
.join('|');
|
||||||
|
const hasHoverSource = map.getSource(srcId) != null;
|
||||||
|
const hasHoverLayers = [symbolId, outlineId, haloId].every((id) => map.getLayer(id));
|
||||||
|
if (hoverSignature === globeHoverShipSignatureRef.current && hasHoverSource && hasHoverLayers) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
globeHoverShipSignatureRef.current = hoverSignature;
|
||||||
|
const needReorder = !hasHoverSource || !hasHoverLayers;
|
||||||
|
|
||||||
|
const hoverGeojson: GeoJSON.FeatureCollection<GeoJSON.Point> = {
|
||||||
|
type: 'FeatureCollection',
|
||||||
|
features: hovered.map((t) => {
|
||||||
|
const legacy = legacyHits?.get(t.mmsi) ?? null;
|
||||||
|
const heading = getDisplayHeading({
|
||||||
|
cog: t.cog,
|
||||||
|
heading: t.heading,
|
||||||
|
offset: GLOBE_ICON_HEADING_OFFSET_DEG,
|
||||||
|
});
|
||||||
|
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 scale = selected ? 1.16 : 1.1;
|
||||||
|
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 || '',
|
||||||
|
cog: heading,
|
||||||
|
heading,
|
||||||
|
sog: isFiniteNumber(t.sog) ? t.sog : 0,
|
||||||
|
shipColor: getGlobeBaseShipColor({
|
||||||
|
legacy: legacy?.shipCode || null,
|
||||||
|
sog: isFiniteNumber(t.sog) ? t.sog : null,
|
||||||
|
}),
|
||||||
|
iconSize3: clampNumber(0.35 * sizeScale * scale, 0.25, 1.45),
|
||||||
|
iconSize7: clampNumber(0.45 * sizeScale * scale, 0.3, 1.7),
|
||||||
|
iconSize10: clampNumber(0.58 * sizeScale * scale, 0.35, 2.1),
|
||||||
|
iconSize14: clampNumber(0.85 * sizeScale * scale, 0.45, 3.0),
|
||||||
|
iconSize18: clampNumber(2.5 * sizeScale * scale, 1.0, 7.0),
|
||||||
|
selected: selected ? 1 : 0,
|
||||||
|
permitted: legacy ? 1 : 0,
|
||||||
|
},
|
||||||
|
};
|
||||||
|
}),
|
||||||
|
};
|
||||||
|
|
||||||
|
try {
|
||||||
|
const existing = map.getSource(srcId) as GeoJSONSource | undefined;
|
||||||
|
if (existing) existing.setData(hoverGeojson);
|
||||||
|
else map.addSource(srcId, { type: 'geojson', data: hoverGeojson } as GeoJSONSourceSpecification);
|
||||||
|
} catch (e) {
|
||||||
|
console.warn('Ship hover source setup failed:', e);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const before = undefined;
|
||||||
|
|
||||||
|
if (!map.getLayer(haloId)) {
|
||||||
|
try {
|
||||||
|
map.addLayer(
|
||||||
|
{
|
||||||
|
id: haloId,
|
||||||
|
type: 'circle',
|
||||||
|
source: srcId,
|
||||||
|
layout: {
|
||||||
|
visibility: 'visible',
|
||||||
|
'circle-sort-key': [
|
||||||
|
'case',
|
||||||
|
['==', ['get', 'selected'], 1], 120,
|
||||||
|
['==', ['get', 'permitted'], 1], 115,
|
||||||
|
110,
|
||||||
|
] as never,
|
||||||
|
},
|
||||||
|
paint: {
|
||||||
|
'circle-radius': GLOBE_SHIP_CIRCLE_RADIUS_EXPR,
|
||||||
|
'circle-color': [
|
||||||
|
'case',
|
||||||
|
['==', ['get', 'selected'], 1], 'rgba(14,234,255,1)',
|
||||||
|
'rgba(245,158,11,1)',
|
||||||
|
] as never,
|
||||||
|
'circle-opacity': 0.42,
|
||||||
|
},
|
||||||
|
} as unknown as LayerSpecification,
|
||||||
|
before,
|
||||||
|
);
|
||||||
|
} catch (e) {
|
||||||
|
console.warn('Ship hover halo layer add failed:', e);
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
map.setLayoutProperty(haloId, 'visibility', 'visible');
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!map.getLayer(outlineId)) {
|
||||||
|
try {
|
||||||
|
map.addLayer(
|
||||||
|
{
|
||||||
|
id: outlineId,
|
||||||
|
type: 'circle',
|
||||||
|
source: srcId,
|
||||||
|
paint: {
|
||||||
|
'circle-radius': GLOBE_SHIP_CIRCLE_RADIUS_EXPR,
|
||||||
|
'circle-color': 'rgba(0,0,0,0)',
|
||||||
|
'circle-stroke-color': [
|
||||||
|
'case',
|
||||||
|
['==', ['get', 'selected'], 1], 'rgba(14,234,255,0.95)',
|
||||||
|
'rgba(245,158,11,0.95)',
|
||||||
|
] as never,
|
||||||
|
'circle-stroke-width': [
|
||||||
|
'case',
|
||||||
|
['==', ['get', 'selected'], 1], 3.8,
|
||||||
|
2.2,
|
||||||
|
] as never,
|
||||||
|
'circle-stroke-opacity': 0.9,
|
||||||
|
},
|
||||||
|
layout: {
|
||||||
|
visibility: 'visible',
|
||||||
|
'circle-sort-key': [
|
||||||
|
'case',
|
||||||
|
['==', ['get', 'selected'], 1], 121,
|
||||||
|
['==', ['get', 'permitted'], 1], 116,
|
||||||
|
111,
|
||||||
|
] as never,
|
||||||
|
},
|
||||||
|
} as unknown as LayerSpecification,
|
||||||
|
before,
|
||||||
|
);
|
||||||
|
} catch (e) {
|
||||||
|
console.warn('Ship hover outline layer add failed:', e);
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
map.setLayoutProperty(outlineId, 'visibility', 'visible');
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!map.getLayer(symbolId)) {
|
||||||
|
try {
|
||||||
|
map.addLayer(
|
||||||
|
{
|
||||||
|
id: symbolId,
|
||||||
|
type: 'symbol',
|
||||||
|
source: srcId,
|
||||||
|
layout: {
|
||||||
|
visibility: 'visible',
|
||||||
|
'symbol-sort-key': [
|
||||||
|
'case',
|
||||||
|
['==', ['get', 'selected'], 1], 122,
|
||||||
|
['==', ['get', 'permitted'], 1], 117,
|
||||||
|
112,
|
||||||
|
] as never,
|
||||||
|
'icon-image': imgId,
|
||||||
|
'icon-size': [
|
||||||
|
'interpolate', ['linear'], ['zoom'],
|
||||||
|
3, ['to-number', ['get', 'iconSize3'], 0.35],
|
||||||
|
7, ['to-number', ['get', 'iconSize7'], 0.45],
|
||||||
|
10, ['to-number', ['get', 'iconSize10'], 0.58],
|
||||||
|
14, ['to-number', ['get', 'iconSize14'], 0.85],
|
||||||
|
18, ['to-number', ['get', 'iconSize18'], 2.5],
|
||||||
|
] as unknown as number[],
|
||||||
|
'icon-allow-overlap': true,
|
||||||
|
'icon-ignore-placement': true,
|
||||||
|
'icon-anchor': 'center',
|
||||||
|
'icon-rotate': ['to-number', ['get', 'heading'], 0],
|
||||||
|
'icon-rotation-alignment': 'map',
|
||||||
|
'icon-pitch-alignment': 'map',
|
||||||
|
},
|
||||||
|
paint: {
|
||||||
|
'icon-color': ['coalesce', ['get', 'shipColor'], '#64748b'] as never,
|
||||||
|
'icon-opacity': 1,
|
||||||
|
},
|
||||||
|
} as unknown as LayerSpecification,
|
||||||
|
before,
|
||||||
|
);
|
||||||
|
} catch (e) {
|
||||||
|
console.warn('Ship hover symbol layer add failed:', e);
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
map.setLayoutProperty(symbolId, 'visibility', 'visible');
|
||||||
|
}
|
||||||
|
|
||||||
|
if (needReorder) {
|
||||||
|
reorderGlobeFeatureLayers();
|
||||||
|
}
|
||||||
|
kickRepaint(map);
|
||||||
|
};
|
||||||
|
|
||||||
|
const stop = onMapStyleReady(map, ensure);
|
||||||
|
return () => {
|
||||||
|
stop();
|
||||||
|
};
|
||||||
|
}, [
|
||||||
|
projection,
|
||||||
|
settings.showShips,
|
||||||
|
shipLayerData,
|
||||||
|
legacyHits,
|
||||||
|
shipHoverOverlaySet,
|
||||||
|
selectedMmsi,
|
||||||
|
mapSyncEpoch,
|
||||||
|
reorderGlobeFeatureLayers,
|
||||||
|
]);
|
||||||
|
|
||||||
|
// Globe ship click selection
|
||||||
|
useEffect(() => {
|
||||||
|
const map = mapRef.current;
|
||||||
|
if (!map) return;
|
||||||
|
if (projection !== 'globe' || !settings.showShips) return;
|
||||||
|
|
||||||
|
const symbolId = 'ships-globe';
|
||||||
|
const symbolLiteId = 'ships-globe-lite';
|
||||||
|
const haloId = 'ships-globe-halo';
|
||||||
|
const outlineId = 'ships-globe-outline';
|
||||||
|
const clickedRadiusDeg2 = Math.pow(0.08, 2);
|
||||||
|
|
||||||
|
const onClick = (e: maplibregl.MapMouseEvent) => {
|
||||||
|
try {
|
||||||
|
const layerIds = [symbolId, symbolLiteId, haloId, outlineId].filter((id) => map.getLayer(id));
|
||||||
|
let feats: unknown[] = [];
|
||||||
|
if (layerIds.length > 0) {
|
||||||
|
try {
|
||||||
|
feats = map.queryRenderedFeatures(e.point, { layers: layerIds }) as unknown[];
|
||||||
|
} catch {
|
||||||
|
feats = [];
|
||||||
|
}
|
||||||
|
}
|
||||||
|
const f = feats?.[0];
|
||||||
|
const props = ((f as { properties?: Record<string, unknown> } | undefined)?.properties || {}) as Record<
|
||||||
|
string,
|
||||||
|
unknown
|
||||||
|
>;
|
||||||
|
const mmsi = Number(props.mmsi);
|
||||||
|
if (Number.isFinite(mmsi)) {
|
||||||
|
if (hasAuxiliarySelectModifier(e as unknown as { shiftKey?: boolean; ctrlKey?: boolean; metaKey?: boolean })) {
|
||||||
|
onToggleHighlightMmsi?.(mmsi);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
onSelectMmsi(mmsi);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const clicked = { lat: e.lngLat.lat, lon: e.lngLat.lng };
|
||||||
|
const cosLat = Math.cos(clicked.lat * DEG2RAD);
|
||||||
|
let bestMmsi: number | null = null;
|
||||||
|
let bestD2 = Number.POSITIVE_INFINITY;
|
||||||
|
for (const t of targets) {
|
||||||
|
if (!isFiniteNumber(t.lat) || !isFiniteNumber(t.lon)) continue;
|
||||||
|
const dLon = (clicked.lon - t.lon) * cosLat;
|
||||||
|
const dLat = clicked.lat - t.lat;
|
||||||
|
const d2 = dLon * dLon + dLat * dLat;
|
||||||
|
if (d2 <= clickedRadiusDeg2 && d2 < bestD2) {
|
||||||
|
bestD2 = d2;
|
||||||
|
bestMmsi = t.mmsi;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (bestMmsi != null) {
|
||||||
|
if (hasAuxiliarySelectModifier(e as unknown as { shiftKey?: boolean; ctrlKey?: boolean; metaKey?: boolean })) {
|
||||||
|
onToggleHighlightMmsi?.(bestMmsi);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
onSelectMmsi(bestMmsi);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
} catch {
|
||||||
|
// ignore
|
||||||
|
}
|
||||||
|
onSelectMmsi(null);
|
||||||
|
};
|
||||||
|
|
||||||
|
map.on('click', onClick);
|
||||||
|
return () => {
|
||||||
|
try {
|
||||||
|
map.off('click', onClick);
|
||||||
|
} catch {
|
||||||
|
// ignore
|
||||||
|
}
|
||||||
|
};
|
||||||
|
}, [projection, settings.showShips, onSelectMmsi, onToggleHighlightMmsi, mapSyncEpoch, targets]);
|
||||||
|
}
|
||||||
66
apps/web/src/widgets/map3d/hooks/useHoverState.ts
Normal file
66
apps/web/src/widgets/map3d/hooks/useHoverState.ts
Normal file
@ -0,0 +1,66 @@
|
|||||||
|
import { useMemo, useState } from 'react';
|
||||||
|
import { toNumberSet } from '../lib/setUtils';
|
||||||
|
|
||||||
|
export interface HoverStateInput {
|
||||||
|
hoveredMmsiSet: number[];
|
||||||
|
hoveredFleetMmsiSet: number[];
|
||||||
|
hoveredPairMmsiSet: number[];
|
||||||
|
hoveredFleetOwnerKey: string | null;
|
||||||
|
highlightedMmsiSet: number[];
|
||||||
|
}
|
||||||
|
|
||||||
|
export function useHoverState(input: HoverStateInput) {
|
||||||
|
const {
|
||||||
|
hoveredMmsiSet,
|
||||||
|
hoveredFleetMmsiSet,
|
||||||
|
hoveredPairMmsiSet,
|
||||||
|
hoveredFleetOwnerKey,
|
||||||
|
highlightedMmsiSet,
|
||||||
|
} = input;
|
||||||
|
|
||||||
|
// Internal deck hover states
|
||||||
|
const [hoveredDeckMmsiSet, setHoveredDeckMmsiSet] = useState<number[]>([]);
|
||||||
|
const [hoveredDeckPairMmsiSet, setHoveredDeckPairMmsiSet] = useState<number[]>([]);
|
||||||
|
const [hoveredDeckFleetOwnerKey, setHoveredDeckFleetOwnerKey] = useState<string | null>(null);
|
||||||
|
const [hoveredDeckFleetMmsiSet, setHoveredDeckFleetMmsiSet] = useState<number[]>([]);
|
||||||
|
const [hoveredZoneId, setHoveredZoneId] = useState<string | null>(null);
|
||||||
|
|
||||||
|
// Derived sets
|
||||||
|
const hoveredMmsiSetRef = useMemo(() => toNumberSet(hoveredMmsiSet), [hoveredMmsiSet]);
|
||||||
|
const hoveredFleetMmsiSetRef = useMemo(() => toNumberSet(hoveredFleetMmsiSet), [hoveredFleetMmsiSet]);
|
||||||
|
const hoveredPairMmsiSetRef = useMemo(() => toNumberSet(hoveredPairMmsiSet), [hoveredPairMmsiSet]);
|
||||||
|
const externalHighlightedSetRef = useMemo(() => toNumberSet(highlightedMmsiSet), [highlightedMmsiSet]);
|
||||||
|
const hoveredDeckMmsiSetRef = useMemo(() => toNumberSet(hoveredDeckMmsiSet), [hoveredDeckMmsiSet]);
|
||||||
|
const hoveredDeckPairMmsiSetRef = useMemo(() => toNumberSet(hoveredDeckPairMmsiSet), [hoveredDeckPairMmsiSet]);
|
||||||
|
const hoveredDeckFleetMmsiSetRef = useMemo(() => toNumberSet(hoveredDeckFleetMmsiSet), [hoveredDeckFleetMmsiSet]);
|
||||||
|
|
||||||
|
const hoveredFleetOwnerKeys = useMemo(() => {
|
||||||
|
const keys = new Set<string>();
|
||||||
|
if (hoveredFleetOwnerKey) keys.add(hoveredFleetOwnerKey);
|
||||||
|
if (hoveredDeckFleetOwnerKey) keys.add(hoveredDeckFleetOwnerKey);
|
||||||
|
return keys;
|
||||||
|
}, [hoveredFleetOwnerKey, hoveredDeckFleetOwnerKey]);
|
||||||
|
|
||||||
|
return {
|
||||||
|
// Internal states + setters
|
||||||
|
hoveredDeckMmsiSet,
|
||||||
|
setHoveredDeckMmsiSet,
|
||||||
|
hoveredDeckPairMmsiSet,
|
||||||
|
setHoveredDeckPairMmsiSet,
|
||||||
|
hoveredDeckFleetOwnerKey,
|
||||||
|
setHoveredDeckFleetOwnerKey,
|
||||||
|
hoveredDeckFleetMmsiSet,
|
||||||
|
setHoveredDeckFleetMmsiSet,
|
||||||
|
hoveredZoneId,
|
||||||
|
setHoveredZoneId,
|
||||||
|
// Derived sets
|
||||||
|
hoveredMmsiSetRef,
|
||||||
|
hoveredFleetMmsiSetRef,
|
||||||
|
hoveredPairMmsiSetRef,
|
||||||
|
externalHighlightedSetRef,
|
||||||
|
hoveredDeckMmsiSetRef,
|
||||||
|
hoveredDeckPairMmsiSetRef,
|
||||||
|
hoveredDeckFleetMmsiSetRef,
|
||||||
|
hoveredFleetOwnerKeys,
|
||||||
|
};
|
||||||
|
}
|
||||||
287
apps/web/src/widgets/map3d/hooks/useMapInit.ts
Normal file
287
apps/web/src/widgets/map3d/hooks/useMapInit.ts
Normal file
@ -0,0 +1,287 @@
|
|||||||
|
import { useCallback, useEffect, useRef, type MutableRefObject, type Dispatch, type SetStateAction } from 'react';
|
||||||
|
import maplibregl, { type StyleSpecification } from 'maplibre-gl';
|
||||||
|
import { MapboxOverlay } from '@deck.gl/mapbox';
|
||||||
|
import { MaplibreDeckCustomLayer } from '../MaplibreDeckCustomLayer';
|
||||||
|
import type { BaseMapId, MapProjectionId, MapViewState } from '../types';
|
||||||
|
import { DECK_VIEW_ID, ANCHORED_SHIP_ICON_ID } from '../constants';
|
||||||
|
import { kickRepaint, onMapStyleReady } from '../lib/mapCore';
|
||||||
|
import { ensureSeamarkOverlay } from '../layers/seamark';
|
||||||
|
import { resolveMapStyle } from '../layers/bathymetry';
|
||||||
|
|
||||||
|
export function useMapInit(
|
||||||
|
containerRef: MutableRefObject<HTMLDivElement | null>,
|
||||||
|
mapRef: MutableRefObject<maplibregl.Map | null>,
|
||||||
|
overlayRef: MutableRefObject<MapboxOverlay | null>,
|
||||||
|
overlayInteractionRef: MutableRefObject<MapboxOverlay | null>,
|
||||||
|
globeDeckLayerRef: MutableRefObject<MaplibreDeckCustomLayer | null>,
|
||||||
|
baseMapRef: MutableRefObject<BaseMapId>,
|
||||||
|
projectionRef: MutableRefObject<MapProjectionId>,
|
||||||
|
opts: {
|
||||||
|
baseMap: BaseMapId;
|
||||||
|
projection: MapProjectionId;
|
||||||
|
showSeamark: boolean;
|
||||||
|
onViewBboxChange?: (bbox: [number, number, number, number]) => void;
|
||||||
|
setMapSyncEpoch: Dispatch<SetStateAction<number>>;
|
||||||
|
initialView?: MapViewState | null;
|
||||||
|
onViewStateChange?: (view: MapViewState) => void;
|
||||||
|
},
|
||||||
|
) {
|
||||||
|
const { onViewBboxChange, setMapSyncEpoch, showSeamark } = opts;
|
||||||
|
const showSeamarkRef = useRef(showSeamark);
|
||||||
|
const onViewStateChangeRef = useRef(opts.onViewStateChange);
|
||||||
|
useEffect(() => { onViewStateChangeRef.current = opts.onViewStateChange; }, [opts.onViewStateChange]);
|
||||||
|
useEffect(() => {
|
||||||
|
showSeamarkRef.current = showSeamark;
|
||||||
|
}, [showSeamark]);
|
||||||
|
|
||||||
|
const ensureMercatorOverlay = useCallback(() => {
|
||||||
|
const map = mapRef.current;
|
||||||
|
if (!map) return null;
|
||||||
|
if (overlayRef.current) return overlayRef.current;
|
||||||
|
try {
|
||||||
|
const next = new MapboxOverlay({ interleaved: true, layers: [] } as unknown as never);
|
||||||
|
map.addControl(next);
|
||||||
|
overlayRef.current = next;
|
||||||
|
return next;
|
||||||
|
} catch (e) {
|
||||||
|
console.warn('Deck overlay create failed:', e);
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
const pulseMapSync = useCallback(() => {
|
||||||
|
setMapSyncEpoch((prev) => prev + 1);
|
||||||
|
requestAnimationFrame(() => {
|
||||||
|
kickRepaint(mapRef.current);
|
||||||
|
setMapSyncEpoch((prev) => prev + 1);
|
||||||
|
});
|
||||||
|
}, [setMapSyncEpoch]);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (!containerRef.current || mapRef.current) return;
|
||||||
|
|
||||||
|
let map: maplibregl.Map | null = null;
|
||||||
|
let cancelled = false;
|
||||||
|
let viewSaveTimer: ReturnType<typeof setInterval> | null = null;
|
||||||
|
const controller = new AbortController();
|
||||||
|
|
||||||
|
(async () => {
|
||||||
|
let style: string | StyleSpecification = '/map/styles/osm-seamark.json';
|
||||||
|
try {
|
||||||
|
style = await resolveMapStyle(baseMapRef.current, controller.signal);
|
||||||
|
} catch (e) {
|
||||||
|
console.warn('Map style init failed, falling back to local raster style:', e);
|
||||||
|
style = '/map/styles/osm-seamark.json';
|
||||||
|
}
|
||||||
|
if (cancelled || !containerRef.current) return;
|
||||||
|
|
||||||
|
const iv = opts.initialView;
|
||||||
|
map = new maplibregl.Map({
|
||||||
|
container: containerRef.current,
|
||||||
|
style,
|
||||||
|
center: iv?.center ?? [126.5, 34.2],
|
||||||
|
zoom: iv?.zoom ?? 7,
|
||||||
|
pitch: iv?.pitch ?? 45,
|
||||||
|
bearing: iv?.bearing ?? 0,
|
||||||
|
maxPitch: 85,
|
||||||
|
dragRotate: true,
|
||||||
|
pitchWithRotate: true,
|
||||||
|
touchPitch: true,
|
||||||
|
scrollZoom: { around: 'center' },
|
||||||
|
});
|
||||||
|
|
||||||
|
map.addControl(new maplibregl.NavigationControl({ showZoom: true, showCompass: true }), 'top-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;
|
||||||
|
|
||||||
|
// 양쪽 overlay를 모두 초기화 — projection 전환 시 재생성 비용 제거
|
||||||
|
ensureMercatorOverlay();
|
||||||
|
globeDeckLayerRef.current = new MaplibreDeckCustomLayer({
|
||||||
|
id: 'deck-globe',
|
||||||
|
viewId: DECK_VIEW_ID,
|
||||||
|
deckProps: { layers: [] },
|
||||||
|
});
|
||||||
|
|
||||||
|
function applyProjection() {
|
||||||
|
if (!map) return;
|
||||||
|
const next = projectionRef.current;
|
||||||
|
if (next === 'mercator') return;
|
||||||
|
try {
|
||||||
|
map.setProjection({ type: next });
|
||||||
|
map.setRenderWorldCopies(next !== 'globe');
|
||||||
|
} catch (e) {
|
||||||
|
console.warn('Projection apply failed:', e);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
onMapStyleReady(map, () => {
|
||||||
|
applyProjection();
|
||||||
|
// deck-globe를 항상 추가 (projection과 무관)
|
||||||
|
const deckLayer = globeDeckLayerRef.current;
|
||||||
|
if (deckLayer && !map!.getLayer(deckLayer.id)) {
|
||||||
|
try {
|
||||||
|
map!.addLayer(deckLayer);
|
||||||
|
} catch {
|
||||||
|
// ignore
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (!showSeamarkRef.current) return;
|
||||||
|
try {
|
||||||
|
ensureSeamarkOverlay(map!, 'bathymetry-lines-coarse');
|
||||||
|
} catch {
|
||||||
|
// ignore
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
const emitBbox = () => {
|
||||||
|
const cb = onViewBboxChange;
|
||||||
|
if (!cb || !map) return;
|
||||||
|
const b = map.getBounds();
|
||||||
|
cb([b.getWest(), b.getSouth(), b.getEast(), b.getNorth()]);
|
||||||
|
};
|
||||||
|
map.on('load', 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', () => {
|
||||||
|
// 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) {
|
||||||
|
try {
|
||||||
|
ensureSeamarkOverlay(map!, 'bathymetry-lines-coarse');
|
||||||
|
} catch {
|
||||||
|
// ignore
|
||||||
|
}
|
||||||
|
try {
|
||||||
|
const opacity = showSeamarkRef.current ? 0.85 : 0;
|
||||||
|
map!.setPaintProperty('seamark', 'raster-opacity', opacity);
|
||||||
|
} catch {
|
||||||
|
// ignore
|
||||||
|
}
|
||||||
|
}
|
||||||
|
// 종속 hook들(useMapStyleSettings 등)이 저장된 설정을 적용하도록 트리거
|
||||||
|
setMapSyncEpoch((prev) => prev + 1);
|
||||||
|
});
|
||||||
|
})();
|
||||||
|
|
||||||
|
return () => {
|
||||||
|
cancelled = true;
|
||||||
|
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 {
|
||||||
|
globeDeckLayerRef.current?.requestFinalize();
|
||||||
|
} catch {
|
||||||
|
// ignore
|
||||||
|
}
|
||||||
|
|
||||||
|
if (map) {
|
||||||
|
map.remove();
|
||||||
|
map = null;
|
||||||
|
}
|
||||||
|
if (overlayRef.current) {
|
||||||
|
overlayRef.current.finalize();
|
||||||
|
overlayRef.current = null;
|
||||||
|
}
|
||||||
|
if (overlayInteractionRef.current) {
|
||||||
|
overlayInteractionRef.current.finalize();
|
||||||
|
overlayInteractionRef.current = null;
|
||||||
|
}
|
||||||
|
globeDeckLayerRef.current = null;
|
||||||
|
mapRef.current = null;
|
||||||
|
};
|
||||||
|
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
return { ensureMercatorOverlay, pulseMapSync };
|
||||||
|
}
|
||||||
182
apps/web/src/widgets/map3d/hooks/useMapStyleSettings.ts
Normal file
182
apps/web/src/widgets/map3d/hooks/useMapStyleSettings.ts
Normal file
@ -0,0 +1,182 @@
|
|||||||
|
import { useEffect, useRef, type MutableRefObject } from 'react';
|
||||||
|
import maplibregl from 'maplibre-gl';
|
||||||
|
import type { MapStyleSettings, MapLabelLanguage, DepthColorStop, DepthFontSize } from '../../../features/mapSettings/types';
|
||||||
|
import type { BaseMapId } from '../types';
|
||||||
|
import { kickRepaint, onMapStyleReady } from '../lib/mapCore';
|
||||||
|
|
||||||
|
/* ── Depth font size presets ──────────────────────────────────────── */
|
||||||
|
const DEPTH_FONT_SIZE_MAP: Record<DepthFontSize, unknown[]> = {
|
||||||
|
small: ['interpolate', ['linear'], ['zoom'], 7, 8, 9, 9, 11, 11, 13, 13],
|
||||||
|
medium: ['interpolate', ['linear'], ['zoom'], 7, 10, 9, 12, 11, 14, 13, 16],
|
||||||
|
large: ['interpolate', ['linear'], ['zoom'], 7, 12, 9, 15, 11, 18, 13, 20],
|
||||||
|
};
|
||||||
|
|
||||||
|
/* ── Helpers ──────────────────────────────────────────────────────── */
|
||||||
|
function darkenHex(hex: string, factor = 0.85): string {
|
||||||
|
const r = parseInt(hex.slice(1, 3), 16);
|
||||||
|
const g = parseInt(hex.slice(3, 5), 16);
|
||||||
|
const b = parseInt(hex.slice(5, 7), 16);
|
||||||
|
return `#${[r, g, b].map((c) => Math.round(c * factor).toString(16).padStart(2, '0')).join('')}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
function lightenHex(hex: string, factor = 1.3): string {
|
||||||
|
const r = parseInt(hex.slice(1, 3), 16);
|
||||||
|
const g = parseInt(hex.slice(3, 5), 16);
|
||||||
|
const b = parseInt(hex.slice(5, 7), 16);
|
||||||
|
return `#${[r, g, b].map((c) => Math.min(255, Math.round(c * factor)).toString(16).padStart(2, '0')).join('')}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ── Apply functions ──────────────────────────────────────────────── */
|
||||||
|
|
||||||
|
function applyLabelLanguage(map: maplibregl.Map, lang: MapLabelLanguage) {
|
||||||
|
const style = map.getStyle();
|
||||||
|
if (!style?.layers) return;
|
||||||
|
for (const layer of style.layers) {
|
||||||
|
if (layer.type !== 'symbol') continue;
|
||||||
|
const layout = (layer as { layout?: Record<string, unknown> }).layout;
|
||||||
|
if (!layout?.['text-field']) continue;
|
||||||
|
if (layer.id.startsWith('bathymetry-labels')) continue;
|
||||||
|
const textField =
|
||||||
|
lang === 'local'
|
||||||
|
? ['get', 'name']
|
||||||
|
: ['coalesce', ['get', `name:${lang}`], ['get', 'name']];
|
||||||
|
try {
|
||||||
|
map.setLayoutProperty(layer.id, 'text-field', textField);
|
||||||
|
} catch {
|
||||||
|
// ignore
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function applyLandColor(map: maplibregl.Map, color: string) {
|
||||||
|
const style = map.getStyle();
|
||||||
|
if (!style?.layers) return;
|
||||||
|
const waterRegex = /(water|sea|ocean|river|lake|coast|bay)/i;
|
||||||
|
const darkVariant = darkenHex(color, 0.8);
|
||||||
|
for (const layer of style.layers) {
|
||||||
|
const id = layer.id;
|
||||||
|
if (id.startsWith('bathymetry-')) continue;
|
||||||
|
if (id.startsWith('subcables-')) continue;
|
||||||
|
if (id.startsWith('zones-')) continue;
|
||||||
|
if (id.startsWith('ships-')) continue;
|
||||||
|
if (id.startsWith('pair-')) continue;
|
||||||
|
if (id.startsWith('fc-')) continue;
|
||||||
|
if (id.startsWith('fleet-')) continue;
|
||||||
|
if (id.startsWith('predict-')) continue;
|
||||||
|
if (id.startsWith('vessel-track-')) continue;
|
||||||
|
if (id === 'deck-globe') continue;
|
||||||
|
const sourceLayer = String((layer as Record<string, unknown>)['source-layer'] ?? '');
|
||||||
|
const isWater = waterRegex.test(id) || waterRegex.test(sourceLayer);
|
||||||
|
if (isWater) continue;
|
||||||
|
try {
|
||||||
|
if (layer.type === 'background') {
|
||||||
|
map.setPaintProperty(id, 'background-color', color);
|
||||||
|
} else if (layer.type === 'fill') {
|
||||||
|
map.setPaintProperty(id, 'fill-color', darkVariant);
|
||||||
|
}
|
||||||
|
} catch {
|
||||||
|
// ignore
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function applyWaterBaseColor(map: maplibregl.Map, fillColor: string) {
|
||||||
|
const style = map.getStyle();
|
||||||
|
if (!style?.layers) return;
|
||||||
|
const waterRegex = /(water|sea|ocean|river|lake|coast|bay)/i;
|
||||||
|
const lineColor = darkenHex(fillColor, 0.85);
|
||||||
|
for (const layer of style.layers) {
|
||||||
|
const id = layer.id;
|
||||||
|
if (id.startsWith('bathymetry-')) continue;
|
||||||
|
const sourceLayer = String((layer as Record<string, unknown>)['source-layer'] ?? '');
|
||||||
|
if (!waterRegex.test(id) && !waterRegex.test(sourceLayer)) continue;
|
||||||
|
try {
|
||||||
|
if (layer.type === 'fill') {
|
||||||
|
map.setPaintProperty(id, 'fill-color', fillColor);
|
||||||
|
} else if (layer.type === 'line') {
|
||||||
|
map.setPaintProperty(id, 'line-color', lineColor);
|
||||||
|
}
|
||||||
|
} catch {
|
||||||
|
// ignore
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function applyDepthGradient(map: maplibregl.Map, stops: DepthColorStop[]) {
|
||||||
|
const depth = ['to-number', ['get', 'depth']];
|
||||||
|
const sorted = [...stops].sort((a, b) => a.depth - b.depth);
|
||||||
|
if (sorted.length === 0) return;
|
||||||
|
const expr: unknown[] = ['interpolate', ['linear'], depth];
|
||||||
|
for (const s of sorted) {
|
||||||
|
expr.push(s.depth, s.color);
|
||||||
|
}
|
||||||
|
// 0m까지 확장 (최천층 stop이 0보다 깊으면)
|
||||||
|
const shallowest = sorted[sorted.length - 1];
|
||||||
|
if (shallowest.depth < 0) {
|
||||||
|
expr.push(0, lightenHex(shallowest.color, 1.8));
|
||||||
|
}
|
||||||
|
if (!map.getLayer('bathymetry-fill')) return;
|
||||||
|
try {
|
||||||
|
map.setPaintProperty('bathymetry-fill', 'fill-color', expr as never);
|
||||||
|
} catch {
|
||||||
|
// ignore
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function applyDepthFontSize(map: maplibregl.Map, size: DepthFontSize) {
|
||||||
|
const expr = DEPTH_FONT_SIZE_MAP[size];
|
||||||
|
for (const layerId of ['bathymetry-labels', 'bathymetry-labels-coarse', 'bathymetry-landforms']) {
|
||||||
|
if (!map.getLayer(layerId)) continue;
|
||||||
|
try {
|
||||||
|
map.setLayoutProperty(layerId, 'text-size', expr);
|
||||||
|
} catch {
|
||||||
|
// ignore
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function applyDepthFontColor(map: maplibregl.Map, color: string) {
|
||||||
|
for (const layerId of ['bathymetry-labels', 'bathymetry-labels-coarse', 'bathymetry-landforms']) {
|
||||||
|
if (!map.getLayer(layerId)) continue;
|
||||||
|
try {
|
||||||
|
map.setPaintProperty(layerId, 'text-color', color);
|
||||||
|
} catch {
|
||||||
|
// ignore
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ── Hook ──────────────────────────────────────────────────────────── */
|
||||||
|
export function useMapStyleSettings(
|
||||||
|
mapRef: MutableRefObject<maplibregl.Map | null>,
|
||||||
|
settings: MapStyleSettings | undefined,
|
||||||
|
opts: { baseMap: BaseMapId; mapSyncEpoch: number },
|
||||||
|
) {
|
||||||
|
const settingsRef = useRef(settings);
|
||||||
|
useEffect(() => {
|
||||||
|
settingsRef.current = settings;
|
||||||
|
});
|
||||||
|
|
||||||
|
const { baseMap, mapSyncEpoch } = opts;
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
const map = mapRef.current;
|
||||||
|
const s = settingsRef.current;
|
||||||
|
if (!map || !s) return;
|
||||||
|
|
||||||
|
const stop = onMapStyleReady(map, () => {
|
||||||
|
applyLabelLanguage(map, s.labelLanguage);
|
||||||
|
applyLandColor(map, s.landColor);
|
||||||
|
applyWaterBaseColor(map, s.waterBaseColor);
|
||||||
|
if (baseMap === 'enhanced') {
|
||||||
|
applyDepthGradient(map, s.depthStops);
|
||||||
|
applyDepthFontSize(map, s.depthFontSize);
|
||||||
|
applyDepthFontColor(map, s.depthFontColor);
|
||||||
|
}
|
||||||
|
kickRepaint(map);
|
||||||
|
});
|
||||||
|
|
||||||
|
return () => stop();
|
||||||
|
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||||
|
}, [settings, baseMap, mapSyncEpoch]);
|
||||||
|
}
|
||||||
184
apps/web/src/widgets/map3d/hooks/useNativeMapLayers.ts
Normal file
184
apps/web/src/widgets/map3d/hooks/useNativeMapLayers.ts
Normal file
@ -0,0 +1,184 @@
|
|||||||
|
/**
|
||||||
|
* useNativeMapLayers — Mercator/Globe 공통 MapLibre 네이티브 레이어 관리 hook
|
||||||
|
*
|
||||||
|
* 반복되는 보일러플레이트를 자동화합니다:
|
||||||
|
* - projectionBusy / isStyleLoaded 가드
|
||||||
|
* - GeoJSON source 생성/업데이트
|
||||||
|
* - Layer 생성 (ensureLayer)
|
||||||
|
* - Visibility 토글
|
||||||
|
* - Globe 레이어 순서 관리 (reorderGlobeFeatureLayers)
|
||||||
|
* - kickRepaint
|
||||||
|
* - Unmount 시 cleanupLayers
|
||||||
|
*
|
||||||
|
* 호버 하이라이트, 마우스 이벤트 등 레이어별 커스텀 로직은
|
||||||
|
* 별도 useEffect에서 처리합니다.
|
||||||
|
*/
|
||||||
|
import { useEffect, useRef, type MutableRefObject } from 'react';
|
||||||
|
import maplibregl, { type GeoJSONSourceSpecification, type LayerSpecification } from 'maplibre-gl';
|
||||||
|
import { ensureGeoJsonSource, ensureLayer, setLayerVisibility, cleanupLayers } from '../lib/layerHelpers';
|
||||||
|
import { kickRepaint, onMapStyleReady } from '../lib/mapCore';
|
||||||
|
|
||||||
|
/* ── Public types ──────────────────────────────────────────────────── */
|
||||||
|
|
||||||
|
export interface NativeSourceConfig {
|
||||||
|
id: string;
|
||||||
|
data: GeoJSON.GeoJSON | null;
|
||||||
|
/** GeoJSON source 옵션 (tolerance, buffer 등) */
|
||||||
|
options?: Partial<Omit<GeoJSONSourceSpecification, 'type' | 'data'>>;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface NativeLayerSpec {
|
||||||
|
id: string;
|
||||||
|
type: 'line' | 'fill' | 'circle' | 'symbol';
|
||||||
|
sourceId: string;
|
||||||
|
paint: Record<string, unknown>;
|
||||||
|
layout?: Record<string, unknown>;
|
||||||
|
filter?: unknown[];
|
||||||
|
minzoom?: number;
|
||||||
|
maxzoom?: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface NativeMapLayersConfig {
|
||||||
|
/** GeoJSON 데이터 소스 (다중 지원) */
|
||||||
|
sources: NativeSourceConfig[];
|
||||||
|
/** 레이어 스펙 배열 (생성 순서대로) */
|
||||||
|
layers: NativeLayerSpec[];
|
||||||
|
/** 전체 레이어 on/off */
|
||||||
|
visible: boolean;
|
||||||
|
/**
|
||||||
|
* 이 레이어들을 삽입할 기준 레이어 ID.
|
||||||
|
* 배열이면 첫 번째로 존재하는 레이어를 사용합니다.
|
||||||
|
*/
|
||||||
|
beforeLayer?: string | string[];
|
||||||
|
/**
|
||||||
|
* 레이어 (재)생성 후 호출되는 콜백.
|
||||||
|
* 호버 하이라이트 재적용 등에 사용합니다.
|
||||||
|
*/
|
||||||
|
onAfterSetup?: (map: maplibregl.Map) => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ── Hook ──────────────────────────────────────────────────────────── */
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @param mapRef - Map 인스턴스 ref
|
||||||
|
* @param projectionBusyRef - 프로젝션 전환 중 가드 ref
|
||||||
|
* @param reorderGlobeFeatureLayers - Globe 레이어 순서 재정렬 함수
|
||||||
|
* @param config - 소스/레이어/visibility 설정
|
||||||
|
* @param deps - 이 값이 변경되면 레이어를 다시 셋업합니다.
|
||||||
|
* (subcableGeo, overlays.subcables, projection, mapSyncEpoch 등)
|
||||||
|
*/
|
||||||
|
export function useNativeMapLayers(
|
||||||
|
mapRef: MutableRefObject<maplibregl.Map | null>,
|
||||||
|
projectionBusyRef: MutableRefObject<boolean>,
|
||||||
|
reorderGlobeFeatureLayers: () => void,
|
||||||
|
config: NativeMapLayersConfig,
|
||||||
|
deps: readonly unknown[],
|
||||||
|
) {
|
||||||
|
// 최신 config를 항상 읽기 위한 ref (deps에 config 객체를 넣지 않기 위함)
|
||||||
|
const configRef = useRef(config);
|
||||||
|
useEffect(() => {
|
||||||
|
configRef.current = config;
|
||||||
|
});
|
||||||
|
|
||||||
|
/* ── 레이어 생성/데이터 업데이트 ─────────────────────────────────── */
|
||||||
|
useEffect(() => {
|
||||||
|
const map = mapRef.current;
|
||||||
|
if (!map) return;
|
||||||
|
let cancelled = false;
|
||||||
|
let retryRaf: number | null = null;
|
||||||
|
|
||||||
|
const ensure = () => {
|
||||||
|
if (cancelled) return;
|
||||||
|
const cfg = configRef.current;
|
||||||
|
|
||||||
|
// 1. Visibility 토글
|
||||||
|
for (const spec of cfg.layers) {
|
||||||
|
setLayerVisibility(map, spec.id, cfg.visible);
|
||||||
|
}
|
||||||
|
|
||||||
|
// projection transition 중에는 가시성 토글만 먼저 반영하고,
|
||||||
|
// source/layer 업데이트는 transition 종료 후 재시도한다.
|
||||||
|
if (projectionBusyRef.current) {
|
||||||
|
if (cfg.visible) {
|
||||||
|
retryRaf = requestAnimationFrame(ensure);
|
||||||
|
}
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// 2. 데이터가 있는 source가 하나도 없으면 종료
|
||||||
|
const hasData = cfg.sources.some((s) => s.data != null);
|
||||||
|
if (!hasData) return;
|
||||||
|
if (!map.isStyleLoaded()) return;
|
||||||
|
|
||||||
|
try {
|
||||||
|
// 3. Source 생성/업데이트
|
||||||
|
for (const src of cfg.sources) {
|
||||||
|
if (src.data) {
|
||||||
|
ensureGeoJsonSource(map, src.id, src.data, src.options);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 4. Before layer 해석
|
||||||
|
let before: string | undefined;
|
||||||
|
if (cfg.beforeLayer) {
|
||||||
|
const candidates = Array.isArray(cfg.beforeLayer) ? cfg.beforeLayer : [cfg.beforeLayer];
|
||||||
|
for (const candidate of candidates) {
|
||||||
|
if (map.getLayer(candidate)) {
|
||||||
|
before = candidate;
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 5. Layer 생성
|
||||||
|
const vis = cfg.visible ? 'visible' : 'none';
|
||||||
|
for (const spec of cfg.layers) {
|
||||||
|
const layerDef: Record<string, unknown> = {
|
||||||
|
id: spec.id,
|
||||||
|
type: spec.type,
|
||||||
|
source: spec.sourceId,
|
||||||
|
paint: spec.paint,
|
||||||
|
layout: { ...spec.layout, visibility: vis },
|
||||||
|
};
|
||||||
|
if (spec.filter) layerDef.filter = spec.filter;
|
||||||
|
if (spec.minzoom != null) layerDef.minzoom = spec.minzoom;
|
||||||
|
if (spec.maxzoom != null) layerDef.maxzoom = spec.maxzoom;
|
||||||
|
|
||||||
|
ensureLayer(map, layerDef as unknown as LayerSpecification, { before });
|
||||||
|
}
|
||||||
|
|
||||||
|
// 6. Post-setup callback
|
||||||
|
if (cfg.onAfterSetup) {
|
||||||
|
cfg.onAfterSetup(map);
|
||||||
|
}
|
||||||
|
} catch (e) {
|
||||||
|
console.warn('Native map layers setup failed:', e);
|
||||||
|
} finally {
|
||||||
|
reorderGlobeFeatureLayers();
|
||||||
|
kickRepaint(map);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const stop = onMapStyleReady(map, ensure);
|
||||||
|
return () => {
|
||||||
|
cancelled = true;
|
||||||
|
if (retryRaf != null) {
|
||||||
|
cancelAnimationFrame(retryRaf);
|
||||||
|
}
|
||||||
|
stop();
|
||||||
|
};
|
||||||
|
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||||
|
}, deps);
|
||||||
|
|
||||||
|
/* ── Unmount cleanup ─────────────────────────────────────────────── */
|
||||||
|
useEffect(() => {
|
||||||
|
const mapInstance = mapRef.current;
|
||||||
|
return () => {
|
||||||
|
if (!mapInstance) return;
|
||||||
|
const cfg = configRef.current;
|
||||||
|
const layerIds = [...cfg.layers].reverse().map((l) => l.id);
|
||||||
|
const sourceIds = [...cfg.sources].reverse().map((s) => s.id);
|
||||||
|
cleanupLayers(mapInstance, layerIds, sourceIds);
|
||||||
|
};
|
||||||
|
}, []);
|
||||||
|
}
|
||||||
210
apps/web/src/widgets/map3d/hooks/usePredictionVectors.ts
Normal file
210
apps/web/src/widgets/map3d/hooks/usePredictionVectors.ts
Normal file
@ -0,0 +1,210 @@
|
|||||||
|
import { useEffect, type MutableRefObject } from 'react';
|
||||||
|
import type maplibregl from 'maplibre-gl';
|
||||||
|
import type { GeoJSONSource, GeoJSONSourceSpecification, LayerSpecification } from 'maplibre-gl';
|
||||||
|
import type { AisTarget } from '../../../entities/aisTarget/model/types';
|
||||||
|
import type { LegacyVesselInfo } from '../../../entities/legacyVessel/model/types';
|
||||||
|
import type { MapToggleState } from '../../../features/mapToggles/MapToggles';
|
||||||
|
import type { Map3DSettings, MapProjectionId } from '../types';
|
||||||
|
import { LEGACY_CODE_COLORS_RGB, OTHER_AIS_SPEED_RGB, rgba as rgbaCss } from '../../../shared/lib/map/palette';
|
||||||
|
import { kickRepaint, onMapStyleReady } from '../lib/mapCore';
|
||||||
|
import { destinationPointLngLat } from '../lib/geometry';
|
||||||
|
import { isFiniteNumber } from '../lib/setUtils';
|
||||||
|
import { toValidBearingDeg, lightenColor } from '../lib/shipUtils';
|
||||||
|
|
||||||
|
export function usePredictionVectors(
|
||||||
|
mapRef: MutableRefObject<maplibregl.Map | null>,
|
||||||
|
projectionBusyRef: MutableRefObject<boolean>,
|
||||||
|
reorderGlobeFeatureLayers: () => void,
|
||||||
|
opts: {
|
||||||
|
overlays: MapToggleState;
|
||||||
|
settings: Map3DSettings;
|
||||||
|
shipData: AisTarget[];
|
||||||
|
legacyHits: Map<number, LegacyVesselInfo> | null | undefined;
|
||||||
|
selectedMmsi: number | null;
|
||||||
|
externalHighlightedSetRef: Set<number>;
|
||||||
|
projection: MapProjectionId;
|
||||||
|
baseMap: string;
|
||||||
|
mapSyncEpoch: number;
|
||||||
|
},
|
||||||
|
) {
|
||||||
|
const { overlays, settings, shipData, legacyHits, selectedMmsi, externalHighlightedSetRef, projection, baseMap, mapSyncEpoch } = opts;
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
const map = mapRef.current;
|
||||||
|
if (!map) return;
|
||||||
|
|
||||||
|
const srcId = 'predict-vectors-src';
|
||||||
|
const outlineId = 'predict-vectors-outline';
|
||||||
|
const lineId = 'predict-vectors';
|
||||||
|
const hlOutlineId = 'predict-vectors-hl-outline';
|
||||||
|
const hlId = 'predict-vectors-hl';
|
||||||
|
|
||||||
|
const ensure = () => {
|
||||||
|
if (projectionBusyRef.current) return;
|
||||||
|
if (!map.isStyleLoaded()) return;
|
||||||
|
|
||||||
|
const visibility = overlays.predictVectors ? 'visible' : 'none';
|
||||||
|
|
||||||
|
const horizonMinutes = 15;
|
||||||
|
const horizonSeconds = horizonMinutes * 60;
|
||||||
|
const metersPerSecondPerKnot = 0.514444;
|
||||||
|
|
||||||
|
const features: GeoJSON.Feature<GeoJSON.LineString>[] = [];
|
||||||
|
if (overlays.predictVectors && settings.showShips && shipData.length > 0) {
|
||||||
|
for (const t of shipData) {
|
||||||
|
const legacy = legacyHits?.get(t.mmsi) ?? null;
|
||||||
|
const isTarget = !!legacy;
|
||||||
|
const isSelected = selectedMmsi != null && t.mmsi === selectedMmsi;
|
||||||
|
const isPinnedHighlight = externalHighlightedSetRef.has(t.mmsi);
|
||||||
|
if (!isTarget && !isSelected && !isPinnedHighlight) continue;
|
||||||
|
|
||||||
|
const sog = isFiniteNumber(t.sog) ? t.sog : null;
|
||||||
|
const bearing = toValidBearingDeg(t.cog) ?? toValidBearingDeg(t.heading);
|
||||||
|
if (sog == null || bearing == null) continue;
|
||||||
|
if (sog < 0.2) continue;
|
||||||
|
|
||||||
|
const distM = sog * metersPerSecondPerKnot * horizonSeconds;
|
||||||
|
if (!Number.isFinite(distM) || distM <= 0) continue;
|
||||||
|
|
||||||
|
const to = destinationPointLngLat([t.lon, t.lat], bearing, distM);
|
||||||
|
|
||||||
|
const baseRgb = isTarget
|
||||||
|
? LEGACY_CODE_COLORS_RGB[legacy?.shipCode ?? ''] ?? OTHER_AIS_SPEED_RGB.moving
|
||||||
|
: OTHER_AIS_SPEED_RGB.moving;
|
||||||
|
const rgb = lightenColor(baseRgb, isTarget ? 0.55 : 0.62);
|
||||||
|
const alpha = isTarget ? 0.72 : 0.52;
|
||||||
|
const alphaHl = isTarget ? 0.92 : 0.84;
|
||||||
|
const hl = isSelected || isPinnedHighlight ? 1 : 0;
|
||||||
|
|
||||||
|
features.push({
|
||||||
|
type: 'Feature',
|
||||||
|
id: `pred-${t.mmsi}`,
|
||||||
|
geometry: { type: 'LineString', coordinates: [[t.lon, t.lat], to] },
|
||||||
|
properties: {
|
||||||
|
mmsi: t.mmsi,
|
||||||
|
minutes: horizonMinutes,
|
||||||
|
sog,
|
||||||
|
cog: bearing,
|
||||||
|
target: isTarget ? 1 : 0,
|
||||||
|
hl,
|
||||||
|
color: rgbaCss(rgb, alpha),
|
||||||
|
colorHl: rgbaCss(rgb, alphaHl),
|
||||||
|
},
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const fc: GeoJSON.FeatureCollection<GeoJSON.LineString> = { type: 'FeatureCollection', features };
|
||||||
|
|
||||||
|
try {
|
||||||
|
const existing = map.getSource(srcId) as GeoJSONSource | undefined;
|
||||||
|
if (existing) existing.setData(fc);
|
||||||
|
else map.addSource(srcId, { type: 'geojson', data: fc } as GeoJSONSourceSpecification);
|
||||||
|
} catch (e) {
|
||||||
|
console.warn('Prediction vector source setup failed:', e);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const ensureLayer = (id: string, paint: LayerSpecification['paint'], filter: unknown[]) => {
|
||||||
|
if (!map.getLayer(id)) {
|
||||||
|
try {
|
||||||
|
map.addLayer(
|
||||||
|
{
|
||||||
|
id,
|
||||||
|
type: 'line',
|
||||||
|
source: srcId,
|
||||||
|
filter: filter as never,
|
||||||
|
layout: {
|
||||||
|
visibility,
|
||||||
|
'line-cap': 'round',
|
||||||
|
'line-join': 'round',
|
||||||
|
},
|
||||||
|
paint,
|
||||||
|
} as unknown as LayerSpecification,
|
||||||
|
undefined,
|
||||||
|
);
|
||||||
|
} catch (e) {
|
||||||
|
console.warn('Prediction vector layer add failed:', e);
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
try {
|
||||||
|
map.setLayoutProperty(id, 'visibility', visibility);
|
||||||
|
map.setFilter(id, filter as never);
|
||||||
|
if (paint && typeof paint === 'object') {
|
||||||
|
for (const [key, value] of Object.entries(paint)) {
|
||||||
|
map.setPaintProperty(id, key as never, value as never);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} catch {
|
||||||
|
// ignore
|
||||||
|
}
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const baseFilter = ['==', ['to-number', ['get', 'hl'], 0], 0] as unknown as unknown[];
|
||||||
|
const hlFilter = ['==', ['to-number', ['get', 'hl'], 0], 1] as unknown as unknown[];
|
||||||
|
|
||||||
|
ensureLayer(
|
||||||
|
outlineId,
|
||||||
|
{
|
||||||
|
'line-color': 'rgba(2,6,23,0.86)',
|
||||||
|
'line-width': 4.8,
|
||||||
|
'line-opacity': 1,
|
||||||
|
'line-blur': 0.2,
|
||||||
|
'line-dasharray': [1.2, 1.8] as never,
|
||||||
|
} as never,
|
||||||
|
baseFilter,
|
||||||
|
);
|
||||||
|
ensureLayer(
|
||||||
|
lineId,
|
||||||
|
{
|
||||||
|
'line-color': ['coalesce', ['get', 'color'], 'rgba(226,232,240,0.62)'] as never,
|
||||||
|
'line-width': 2.4,
|
||||||
|
'line-opacity': 1,
|
||||||
|
'line-dasharray': [1.2, 1.8] as never,
|
||||||
|
} as never,
|
||||||
|
baseFilter,
|
||||||
|
);
|
||||||
|
ensureLayer(
|
||||||
|
hlOutlineId,
|
||||||
|
{
|
||||||
|
'line-color': 'rgba(2,6,23,0.92)',
|
||||||
|
'line-width': 6.4,
|
||||||
|
'line-opacity': 1,
|
||||||
|
'line-blur': 0.25,
|
||||||
|
'line-dasharray': [1.2, 1.8] as never,
|
||||||
|
} as never,
|
||||||
|
hlFilter,
|
||||||
|
);
|
||||||
|
ensureLayer(
|
||||||
|
hlId,
|
||||||
|
{
|
||||||
|
'line-color': ['coalesce', ['get', 'colorHl'], ['get', 'color'], 'rgba(241,245,249,0.92)'] as never,
|
||||||
|
'line-width': 3.6,
|
||||||
|
'line-opacity': 1,
|
||||||
|
'line-dasharray': [1.2, 1.8] as never,
|
||||||
|
} as never,
|
||||||
|
hlFilter,
|
||||||
|
);
|
||||||
|
|
||||||
|
reorderGlobeFeatureLayers();
|
||||||
|
kickRepaint(map);
|
||||||
|
};
|
||||||
|
|
||||||
|
const stop = onMapStyleReady(map, ensure);
|
||||||
|
return () => {
|
||||||
|
stop();
|
||||||
|
};
|
||||||
|
}, [
|
||||||
|
overlays.predictVectors,
|
||||||
|
settings.showShips,
|
||||||
|
shipData,
|
||||||
|
legacyHits,
|
||||||
|
selectedMmsi,
|
||||||
|
externalHighlightedSetRef,
|
||||||
|
projection,
|
||||||
|
baseMap,
|
||||||
|
mapSyncEpoch,
|
||||||
|
reorderGlobeFeatureLayers,
|
||||||
|
]);
|
||||||
|
}
|
||||||
301
apps/web/src/widgets/map3d/hooks/useProjectionToggle.ts
Normal file
301
apps/web/src/widgets/map3d/hooks/useProjectionToggle.ts
Normal file
@ -0,0 +1,301 @@
|
|||||||
|
import { useCallback, useEffect, useRef, type MutableRefObject } from 'react';
|
||||||
|
import type maplibregl from 'maplibre-gl';
|
||||||
|
import { MapboxOverlay } from '@deck.gl/mapbox';
|
||||||
|
import { MaplibreDeckCustomLayer } from '../MaplibreDeckCustomLayer';
|
||||||
|
import type { MapProjectionId } from '../types';
|
||||||
|
import { kickRepaint, onMapStyleReady, extractProjectionType } from '../lib/mapCore';
|
||||||
|
|
||||||
|
export function useProjectionToggle(
|
||||||
|
mapRef: MutableRefObject<maplibregl.Map | null>,
|
||||||
|
overlayRef: MutableRefObject<MapboxOverlay | null>,
|
||||||
|
overlayInteractionRef: MutableRefObject<MapboxOverlay | null>,
|
||||||
|
globeDeckLayerRef: MutableRefObject<MaplibreDeckCustomLayer | null>,
|
||||||
|
projectionBusyRef: MutableRefObject<boolean>,
|
||||||
|
opts: {
|
||||||
|
projection: MapProjectionId;
|
||||||
|
ensureMercatorOverlay: () => MapboxOverlay | null;
|
||||||
|
onProjectionLoadingChange?: (loading: boolean) => void;
|
||||||
|
pulseMapSync: () => void;
|
||||||
|
setMapSyncEpoch: (updater: (prev: number) => number) => void;
|
||||||
|
},
|
||||||
|
): () => void {
|
||||||
|
const { projection, ensureMercatorOverlay, onProjectionLoadingChange, pulseMapSync, setMapSyncEpoch } = opts;
|
||||||
|
|
||||||
|
const projectionBusyTokenRef = useRef(0);
|
||||||
|
const projectionBusyTimerRef = useRef<ReturnType<typeof window.setTimeout> | null>(null);
|
||||||
|
const projectionPrevRef = useRef<MapProjectionId>(projection);
|
||||||
|
const projectionRef = useRef<MapProjectionId>(projection);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
projectionRef.current = projection;
|
||||||
|
}, [projection]);
|
||||||
|
|
||||||
|
const clearProjectionBusyTimer = useCallback(() => {
|
||||||
|
if (projectionBusyTimerRef.current == null) return;
|
||||||
|
clearTimeout(projectionBusyTimerRef.current);
|
||||||
|
projectionBusyTimerRef.current = null;
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
// eslint-disable-next-line react-hooks/preserve-manual-memoization
|
||||||
|
const endProjectionLoading = useCallback(() => {
|
||||||
|
if (!projectionBusyRef.current) return;
|
||||||
|
projectionBusyRef.current = false;
|
||||||
|
clearProjectionBusyTimer();
|
||||||
|
if (onProjectionLoadingChange) {
|
||||||
|
onProjectionLoadingChange(false);
|
||||||
|
}
|
||||||
|
setMapSyncEpoch((prev) => prev + 1);
|
||||||
|
kickRepaint(mapRef.current);
|
||||||
|
}, [clearProjectionBusyTimer, onProjectionLoadingChange, setMapSyncEpoch]);
|
||||||
|
|
||||||
|
const setProjectionLoading = useCallback(
|
||||||
|
// eslint-disable-next-line react-hooks/preserve-manual-memoization
|
||||||
|
(loading: boolean) => {
|
||||||
|
if (projectionBusyRef.current === loading) return;
|
||||||
|
if (!loading) {
|
||||||
|
endProjectionLoading();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
clearProjectionBusyTimer();
|
||||||
|
projectionBusyRef.current = true;
|
||||||
|
const token = ++projectionBusyTokenRef.current;
|
||||||
|
if (onProjectionLoadingChange) {
|
||||||
|
onProjectionLoadingChange(true);
|
||||||
|
}
|
||||||
|
|
||||||
|
projectionBusyTimerRef.current = setTimeout(() => {
|
||||||
|
if (!projectionBusyRef.current || projectionBusyTokenRef.current !== token) return;
|
||||||
|
console.debug('Projection loading fallback timeout reached.');
|
||||||
|
endProjectionLoading();
|
||||||
|
}, 2000);
|
||||||
|
},
|
||||||
|
[clearProjectionBusyTimer, endProjectionLoading, onProjectionLoadingChange],
|
||||||
|
);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
return () => {
|
||||||
|
clearProjectionBusyTimer();
|
||||||
|
endProjectionLoading();
|
||||||
|
};
|
||||||
|
}, [clearProjectionBusyTimer, endProjectionLoading]);
|
||||||
|
|
||||||
|
// eslint-disable-next-line react-hooks/preserve-manual-memoization
|
||||||
|
const reorderGlobeFeatureLayers = useCallback(() => {
|
||||||
|
const map = mapRef.current;
|
||||||
|
if (!map || projectionRef.current !== 'globe') return;
|
||||||
|
if (projectionBusyRef.current) return;
|
||||||
|
if (!map.isStyleLoaded()) return;
|
||||||
|
|
||||||
|
const ordering = [
|
||||||
|
'subcables-hitarea',
|
||||||
|
'subcables-casing',
|
||||||
|
'subcables-line',
|
||||||
|
'subcables-glow',
|
||||||
|
'subcables-points',
|
||||||
|
'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-line',
|
||||||
|
'zones-label',
|
||||||
|
'predict-vectors-outline',
|
||||||
|
'predict-vectors',
|
||||||
|
'predict-vectors-hl-outline',
|
||||||
|
'predict-vectors-hl',
|
||||||
|
'ships-globe-halo',
|
||||||
|
'ships-globe-outline',
|
||||||
|
'ships-globe-lite',
|
||||||
|
'ships-globe',
|
||||||
|
'ships-globe-label',
|
||||||
|
'ships-globe-hover-halo',
|
||||||
|
'ships-globe-hover-outline',
|
||||||
|
'ships-globe-hover',
|
||||||
|
'pair-lines-ml',
|
||||||
|
'fc-lines-ml',
|
||||||
|
'pair-range-ml',
|
||||||
|
'fleet-circles-ml-fill',
|
||||||
|
'fleet-circles-ml',
|
||||||
|
];
|
||||||
|
|
||||||
|
for (const layerId of ordering) {
|
||||||
|
try {
|
||||||
|
if (map.getLayer(layerId)) map.moveLayer(layerId);
|
||||||
|
} catch {
|
||||||
|
// ignore
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
kickRepaint(map);
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
// Projection toggle (mercator <-> globe)
|
||||||
|
useEffect(() => {
|
||||||
|
const map = mapRef.current;
|
||||||
|
if (!map) return;
|
||||||
|
let cancelled = false;
|
||||||
|
let retries = 0;
|
||||||
|
const maxRetries = 18;
|
||||||
|
const isTransition = projectionPrevRef.current !== projection;
|
||||||
|
projectionPrevRef.current = projection;
|
||||||
|
let settleScheduled = false;
|
||||||
|
let settleCleanup: (() => void) | null = null;
|
||||||
|
|
||||||
|
const startProjectionSettle = () => {
|
||||||
|
if (!isTransition || settleScheduled) return;
|
||||||
|
settleScheduled = true;
|
||||||
|
|
||||||
|
const finalize = () => {
|
||||||
|
if (!cancelled && isTransition) setProjectionLoading(false);
|
||||||
|
};
|
||||||
|
|
||||||
|
const finalizeSoon = () => {
|
||||||
|
if (cancelled || !isTransition || projectionBusyRef.current === false) return;
|
||||||
|
if (!map.isStyleLoaded()) {
|
||||||
|
requestAnimationFrame(finalizeSoon);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
requestAnimationFrame(finalize);
|
||||||
|
};
|
||||||
|
|
||||||
|
const onIdle = () => finalizeSoon();
|
||||||
|
try {
|
||||||
|
map.on('idle', onIdle);
|
||||||
|
const styleReadyCleanup = onMapStyleReady(map, finalizeSoon);
|
||||||
|
settleCleanup = () => {
|
||||||
|
map.off('idle', onIdle);
|
||||||
|
styleReadyCleanup();
|
||||||
|
};
|
||||||
|
} catch {
|
||||||
|
requestAnimationFrame(finalize);
|
||||||
|
settleCleanup = null;
|
||||||
|
}
|
||||||
|
|
||||||
|
finalizeSoon();
|
||||||
|
};
|
||||||
|
|
||||||
|
if (isTransition) setProjectionLoading(true);
|
||||||
|
|
||||||
|
// 파괴하지 않고 레이어만 비움 — 양쪽 파이프라인 항상 유지
|
||||||
|
const quietMercatorOverlays = () => {
|
||||||
|
try { overlayRef.current?.setProps({ layers: [] } as never); } catch { /* ignore */ }
|
||||||
|
try { overlayInteractionRef.current?.setProps({ layers: [] } as never); } catch { /* ignore */ }
|
||||||
|
};
|
||||||
|
|
||||||
|
const quietGlobeDeckLayer = () => {
|
||||||
|
try { globeDeckLayerRef.current?.setProps({ layers: [] } as never); } catch { /* ignore */ }
|
||||||
|
};
|
||||||
|
|
||||||
|
const syncProjectionAndDeck = () => {
|
||||||
|
if (cancelled) return;
|
||||||
|
if (!isTransition) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!map.isStyleLoaded()) {
|
||||||
|
if (!cancelled && retries < maxRetries) {
|
||||||
|
retries += 1;
|
||||||
|
window.requestAnimationFrame(() => syncProjectionAndDeck());
|
||||||
|
}
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const next = projection;
|
||||||
|
const currentProjection = extractProjectionType(map);
|
||||||
|
const shouldSwitchProjection = currentProjection !== next;
|
||||||
|
|
||||||
|
if (projection === 'globe') {
|
||||||
|
quietMercatorOverlays();
|
||||||
|
} else {
|
||||||
|
quietGlobeDeckLayer();
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
if (shouldSwitchProjection) {
|
||||||
|
map.setProjection({ type: next });
|
||||||
|
}
|
||||||
|
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) {
|
||||||
|
retries += 1;
|
||||||
|
window.requestAnimationFrame(() => syncProjectionAndDeck());
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
} catch (e) {
|
||||||
|
if (!cancelled && retries < maxRetries) {
|
||||||
|
retries += 1;
|
||||||
|
window.requestAnimationFrame(() => syncProjectionAndDeck());
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (isTransition) setProjectionLoading(false);
|
||||||
|
console.warn('Projection switch failed:', e);
|
||||||
|
}
|
||||||
|
|
||||||
|
// 양쪽 overlay가 항상 존재하므로 재생성 불필요
|
||||||
|
// deck-globe가 map에서 빠져있을 경우에만 재추가
|
||||||
|
if (projection === 'globe') {
|
||||||
|
const layer = globeDeckLayerRef.current;
|
||||||
|
const layerId = layer?.id;
|
||||||
|
if (layer && layerId && map.isStyleLoaded() && !map.getLayer(layerId)) {
|
||||||
|
try {
|
||||||
|
map.addLayer(layer);
|
||||||
|
} catch {
|
||||||
|
// ignore
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
ensureMercatorOverlay();
|
||||||
|
}
|
||||||
|
|
||||||
|
reorderGlobeFeatureLayers();
|
||||||
|
kickRepaint(map);
|
||||||
|
try {
|
||||||
|
map.resize();
|
||||||
|
} catch {
|
||||||
|
// ignore
|
||||||
|
}
|
||||||
|
if (isTransition) {
|
||||||
|
startProjectionSettle();
|
||||||
|
}
|
||||||
|
pulseMapSync();
|
||||||
|
};
|
||||||
|
|
||||||
|
if (!isTransition) return;
|
||||||
|
|
||||||
|
if (map.isStyleLoaded()) syncProjectionAndDeck();
|
||||||
|
else {
|
||||||
|
const stop = onMapStyleReady(map, syncProjectionAndDeck);
|
||||||
|
return () => {
|
||||||
|
cancelled = true;
|
||||||
|
if (settleCleanup) settleCleanup();
|
||||||
|
stop();
|
||||||
|
if (isTransition) setProjectionLoading(false);
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
return () => {
|
||||||
|
cancelled = true;
|
||||||
|
if (settleCleanup) settleCleanup();
|
||||||
|
if (isTransition) setProjectionLoading(false);
|
||||||
|
};
|
||||||
|
}, [projection, ensureMercatorOverlay, reorderGlobeFeatureLayers, setProjectionLoading]);
|
||||||
|
|
||||||
|
return reorderGlobeFeatureLayers;
|
||||||
|
}
|
||||||
300
apps/web/src/widgets/map3d/hooks/useSubcablesLayer.ts
Normal file
300
apps/web/src/widgets/map3d/hooks/useSubcablesLayer.ts
Normal file
@ -0,0 +1,300 @@
|
|||||||
|
import { useEffect, useMemo, useRef, type MutableRefObject } from 'react';
|
||||||
|
import maplibregl from 'maplibre-gl';
|
||||||
|
import type { SubcableGeoJson } from '../../../entities/subcable/model/types';
|
||||||
|
import type { MapToggleState } from '../../../features/mapToggles/MapToggles';
|
||||||
|
import type { MapProjectionId } from '../types';
|
||||||
|
import { kickRepaint } from '../lib/mapCore';
|
||||||
|
import { useNativeMapLayers, type NativeLayerSpec } from './useNativeMapLayers';
|
||||||
|
|
||||||
|
/* ── Layer / Source IDs ─────────────────────────────────────────────── */
|
||||||
|
const SRC_ID = 'subcables-src';
|
||||||
|
const POINTS_SRC_ID = 'subcables-pts-src';
|
||||||
|
|
||||||
|
const HITAREA_ID = 'subcables-hitarea';
|
||||||
|
const CASING_ID = 'subcables-casing';
|
||||||
|
const LINE_ID = 'subcables-line';
|
||||||
|
const GLOW_ID = 'subcables-glow';
|
||||||
|
const POINTS_ID = 'subcables-points';
|
||||||
|
const LABEL_ID = 'subcables-label';
|
||||||
|
const HOVER_LABEL_ID = 'subcables-hover-label';
|
||||||
|
|
||||||
|
/* ── 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_WIDTH_DEFAULT = ['interpolate', ['linear'], ['zoom'], 2, 1.6, 6, 2.5, 10, 4.0];
|
||||||
|
const CASING_OPACITY_DEFAULT = ['interpolate', ['linear'], ['zoom'], 2, 0.3, 5, 0.5, 8, 0.65];
|
||||||
|
const CASING_WIDTH_DEFAULT = ['interpolate', ['linear'], ['zoom'], 2, 2.4, 6, 3.6, 10, 5.5];
|
||||||
|
const POINTS_OPACITY_DEFAULT = ['interpolate', ['linear'], ['zoom'], 2, 0.5, 5, 0.7, 8, 0.85];
|
||||||
|
const POINTS_RADIUS_DEFAULT = ['interpolate', ['linear'], ['zoom'], 2, 1.5, 6, 2.5, 10, 4];
|
||||||
|
|
||||||
|
/* ── Layer specifications ────────────────────────────────────────── */
|
||||||
|
const LAYER_SPECS: NativeLayerSpec[] = [
|
||||||
|
{
|
||||||
|
id: HITAREA_ID,
|
||||||
|
type: 'line',
|
||||||
|
sourceId: SRC_ID,
|
||||||
|
paint: { 'line-color': 'rgba(0,0,0,0)', 'line-width': 14, 'line-opacity': 0 },
|
||||||
|
layout: { 'line-cap': 'round', 'line-join': 'round' },
|
||||||
|
minzoom: 3,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: CASING_ID,
|
||||||
|
type: 'line',
|
||||||
|
sourceId: SRC_ID,
|
||||||
|
paint: {
|
||||||
|
'line-color': 'rgba(0,0,0,0.55)',
|
||||||
|
'line-width': CASING_WIDTH_DEFAULT,
|
||||||
|
'line-opacity': CASING_OPACITY_DEFAULT,
|
||||||
|
},
|
||||||
|
layout: { 'line-cap': 'round', 'line-join': 'round' },
|
||||||
|
minzoom: 3,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: LINE_ID,
|
||||||
|
type: 'line',
|
||||||
|
sourceId: SRC_ID,
|
||||||
|
paint: {
|
||||||
|
'line-color': ['get', 'color'],
|
||||||
|
'line-opacity': LINE_OPACITY_DEFAULT,
|
||||||
|
'line-width': LINE_WIDTH_DEFAULT,
|
||||||
|
},
|
||||||
|
layout: { 'line-cap': 'round', 'line-join': 'round' },
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: GLOW_ID,
|
||||||
|
type: 'line',
|
||||||
|
sourceId: SRC_ID,
|
||||||
|
paint: {
|
||||||
|
'line-color': '#ffffff',
|
||||||
|
'line-opacity': 0,
|
||||||
|
'line-width': ['interpolate', ['linear'], ['zoom'], 2, 10, 6, 16, 10, 24],
|
||||||
|
'line-blur': ['interpolate', ['linear'], ['zoom'], 2, 4, 6, 6, 10, 8],
|
||||||
|
},
|
||||||
|
filter: ['==', ['get', 'id'], ''],
|
||||||
|
layout: { 'line-cap': 'round', 'line-join': 'round' },
|
||||||
|
minzoom: 3,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: POINTS_ID,
|
||||||
|
type: 'circle',
|
||||||
|
sourceId: POINTS_SRC_ID,
|
||||||
|
paint: {
|
||||||
|
'circle-radius': POINTS_RADIUS_DEFAULT,
|
||||||
|
'circle-color': ['get', 'color'],
|
||||||
|
'circle-opacity': POINTS_OPACITY_DEFAULT,
|
||||||
|
'circle-stroke-color': 'rgba(0,0,0,0.5)',
|
||||||
|
'circle-stroke-width': 0.5,
|
||||||
|
},
|
||||||
|
minzoom: 3,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: LABEL_ID,
|
||||||
|
type: 'symbol',
|
||||||
|
sourceId: SRC_ID,
|
||||||
|
paint: {
|
||||||
|
'text-color': 'rgba(220,232,245,0.82)',
|
||||||
|
'text-halo-color': 'rgba(2,6,23,0.9)',
|
||||||
|
'text-halo-width': 1.2,
|
||||||
|
'text-halo-blur': 0.5,
|
||||||
|
'text-opacity': ['interpolate', ['linear'], ['zoom'], 4, 0, 5, 0.7, 8, 0.88],
|
||||||
|
},
|
||||||
|
layout: {
|
||||||
|
'symbol-placement': 'line',
|
||||||
|
'text-field': ['get', 'name'],
|
||||||
|
'text-size': ['interpolate', ['linear'], ['zoom'], 4, 9, 8, 11, 12, 13],
|
||||||
|
'text-font': ['Noto Sans Regular', 'Open Sans Regular'],
|
||||||
|
'text-allow-overlap': false,
|
||||||
|
'text-padding': 8,
|
||||||
|
'text-rotation-alignment': 'map',
|
||||||
|
},
|
||||||
|
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(
|
||||||
|
mapRef: MutableRefObject<maplibregl.Map | null>,
|
||||||
|
projectionBusyRef: MutableRefObject<boolean>,
|
||||||
|
reorderGlobeFeatureLayers: () => void,
|
||||||
|
opts: {
|
||||||
|
subcableGeo: SubcableGeoJson | null;
|
||||||
|
overlays: MapToggleState;
|
||||||
|
projection: MapProjectionId;
|
||||||
|
mapSyncEpoch: number;
|
||||||
|
hoveredCableId: string | null;
|
||||||
|
onHoverCable: (cableId: string | null) => void;
|
||||||
|
onClickCable: (cableId: string | null) => void;
|
||||||
|
},
|
||||||
|
) {
|
||||||
|
const { subcableGeo, overlays, projection, mapSyncEpoch, hoveredCableId, onHoverCable, onClickCable } = opts;
|
||||||
|
|
||||||
|
const onHoverRef = useRef(onHoverCable);
|
||||||
|
const onClickRef = useRef(onClickCable);
|
||||||
|
const hoveredCableIdRef = useRef(hoveredCableId);
|
||||||
|
useEffect(() => {
|
||||||
|
onHoverRef.current = onHoverCable;
|
||||||
|
onClickRef.current = onClickCable;
|
||||||
|
hoveredCableIdRef.current = hoveredCableId;
|
||||||
|
});
|
||||||
|
|
||||||
|
/* ── Derived point features ──────────────────────────────────────── */
|
||||||
|
const pointsGeoJson = useMemo<GeoJSON.FeatureCollection>(() => {
|
||||||
|
if (!subcableGeo) return { type: 'FeatureCollection', features: [] };
|
||||||
|
const features: GeoJSON.Feature<GeoJSON.Point>[] = [];
|
||||||
|
for (const f of subcableGeo.features) {
|
||||||
|
const coords = f.properties.coordinates;
|
||||||
|
if (!coords || coords.length < 2) continue;
|
||||||
|
features.push({
|
||||||
|
type: 'Feature',
|
||||||
|
properties: { id: f.properties.id, color: f.properties.color },
|
||||||
|
geometry: { type: 'Point', coordinates: coords },
|
||||||
|
});
|
||||||
|
}
|
||||||
|
return { type: 'FeatureCollection', features };
|
||||||
|
}, [subcableGeo]);
|
||||||
|
|
||||||
|
/* ================================================================
|
||||||
|
* Effect 1: Layer creation & data update (via useNativeMapLayers)
|
||||||
|
* ================================================================ */
|
||||||
|
useNativeMapLayers(
|
||||||
|
mapRef,
|
||||||
|
projectionBusyRef,
|
||||||
|
reorderGlobeFeatureLayers,
|
||||||
|
{
|
||||||
|
sources: [
|
||||||
|
{ id: SRC_ID, data: subcableGeo, options: { tolerance: 1, buffer: 64 } },
|
||||||
|
{ id: POINTS_SRC_ID, data: pointsGeoJson },
|
||||||
|
],
|
||||||
|
layers: LAYER_SPECS,
|
||||||
|
visible: overlays.subcables,
|
||||||
|
beforeLayer: ['zones-fill', 'deck-globe'],
|
||||||
|
onAfterSetup: (map) => applyHoverHighlight(map, hoveredCableIdRef.current),
|
||||||
|
},
|
||||||
|
[subcableGeo, pointsGeoJson, overlays.subcables, projection, mapSyncEpoch, reorderGlobeFeatureLayers],
|
||||||
|
);
|
||||||
|
|
||||||
|
/* ================================================================
|
||||||
|
* Effect 2: Hover highlight (paint-only, no layer creation)
|
||||||
|
* ================================================================ */
|
||||||
|
useEffect(() => {
|
||||||
|
const map = mapRef.current;
|
||||||
|
if (!map || !map.isStyleLoaded()) return;
|
||||||
|
if (projectionBusyRef.current) return;
|
||||||
|
if (!map.getLayer(LINE_ID)) return;
|
||||||
|
|
||||||
|
applyHoverHighlight(map, hoveredCableId);
|
||||||
|
kickRepaint(map);
|
||||||
|
}, [hoveredCableId]);
|
||||||
|
|
||||||
|
/* ================================================================
|
||||||
|
* Effect 3: Mouse events (bind to hit-area for easy hovering)
|
||||||
|
* ================================================================ */
|
||||||
|
useEffect(() => {
|
||||||
|
const map = mapRef.current;
|
||||||
|
if (!map) return;
|
||||||
|
if (!overlays.subcables) return;
|
||||||
|
|
||||||
|
let cancelled = false;
|
||||||
|
let retryTimer: ReturnType<typeof setTimeout> | null = null;
|
||||||
|
|
||||||
|
const onMouseMove = (e: maplibregl.MapMouseEvent & { features?: maplibregl.MapGeoJSONFeature[] }) => {
|
||||||
|
const cableId = e.features?.[0]?.properties?.id;
|
||||||
|
if (typeof cableId === 'string' && cableId) {
|
||||||
|
map.getCanvas().style.cursor = 'pointer';
|
||||||
|
onHoverRef.current(cableId);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const onMouseLeave = () => {
|
||||||
|
map.getCanvas().style.cursor = '';
|
||||||
|
onHoverRef.current(null);
|
||||||
|
};
|
||||||
|
|
||||||
|
const onClick = (e: maplibregl.MapMouseEvent & { features?: maplibregl.MapGeoJSONFeature[] }) => {
|
||||||
|
const cableId = e.features?.[0]?.properties?.id;
|
||||||
|
if (typeof cableId === 'string' && cableId) {
|
||||||
|
onClickRef.current(cableId);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const bindEvents = () => {
|
||||||
|
if (cancelled) return;
|
||||||
|
const targetLayer = map.getLayer(HITAREA_ID) ? HITAREA_ID : map.getLayer(LINE_ID) ? LINE_ID : null;
|
||||||
|
if (!targetLayer) {
|
||||||
|
retryTimer = setTimeout(bindEvents, 200);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
map.on('mousemove', targetLayer, onMouseMove);
|
||||||
|
map.on('mouseleave', targetLayer, onMouseLeave);
|
||||||
|
map.on('click', targetLayer, onClick);
|
||||||
|
};
|
||||||
|
|
||||||
|
if (map.isStyleLoaded()) {
|
||||||
|
bindEvents();
|
||||||
|
} else {
|
||||||
|
map.once('idle', bindEvents);
|
||||||
|
}
|
||||||
|
|
||||||
|
return () => {
|
||||||
|
cancelled = true;
|
||||||
|
if (retryTimer) clearTimeout(retryTimer);
|
||||||
|
try {
|
||||||
|
map.off('mousemove', HITAREA_ID, onMouseMove);
|
||||||
|
map.off('mouseleave', HITAREA_ID, onMouseLeave);
|
||||||
|
map.off('click', HITAREA_ID, onClick);
|
||||||
|
map.off('mousemove', LINE_ID, onMouseMove);
|
||||||
|
map.off('mouseleave', LINE_ID, onMouseLeave);
|
||||||
|
map.off('click', LINE_ID, onClick);
|
||||||
|
} catch {
|
||||||
|
// ignore
|
||||||
|
}
|
||||||
|
};
|
||||||
|
}, [overlays.subcables, mapSyncEpoch]);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ── Hover highlight helper (paint-only mutations) ────────────────── */
|
||||||
|
// 기본 레이어는 항상 기본값 유지, glow 레이어(filter 기반)로만 호버 강조
|
||||||
|
function applyHoverHighlight(map: maplibregl.Map, hoveredId: string | null) {
|
||||||
|
const noMatch = ['==', ['get', 'id'], ''] as never;
|
||||||
|
if (hoveredId) {
|
||||||
|
const matchExpr = ['==', ['get', 'id'], hoveredId];
|
||||||
|
if (map.getLayer(GLOW_ID)) {
|
||||||
|
map.setFilter(GLOW_ID, matchExpr as never);
|
||||||
|
map.setPaintProperty(GLOW_ID, 'line-opacity', 0.55);
|
||||||
|
}
|
||||||
|
if (map.getLayer(HOVER_LABEL_ID)) {
|
||||||
|
map.setFilter(HOVER_LABEL_ID, matchExpr as never);
|
||||||
|
map.setPaintProperty(HOVER_LABEL_ID, 'text-opacity', 1.0);
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
if (map.getLayer(GLOW_ID)) {
|
||||||
|
map.setFilter(GLOW_ID, noMatch);
|
||||||
|
map.setPaintProperty(GLOW_ID, 'line-opacity', 0);
|
||||||
|
}
|
||||||
|
if (map.getLayer(HOVER_LABEL_ID)) {
|
||||||
|
map.setFilter(HOVER_LABEL_ID, noMatch);
|
||||||
|
map.setPaintProperty(HOVER_LABEL_ID, 'text-opacity', 0);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
242
apps/web/src/widgets/map3d/hooks/useTrackReplayLayer.ts
Normal file
242
apps/web/src/widgets/map3d/hooks/useTrackReplayLayer.ts
Normal file
@ -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]);
|
||||||
|
}
|
||||||
396
apps/web/src/widgets/map3d/hooks/useVesselTrackLayer.ts
Normal file
396
apps/web/src/widgets/map3d/hooks/useVesselTrackLayer.ts
Normal file
@ -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]);
|
||||||
|
}
|
||||||
262
apps/web/src/widgets/map3d/hooks/useZonesLayer.ts
Normal file
262
apps/web/src/widgets/map3d/hooks/useZonesLayer.ts
Normal file
@ -0,0 +1,262 @@
|
|||||||
|
import { useEffect, useMemo, type MutableRefObject } from 'react';
|
||||||
|
import maplibregl, {
|
||||||
|
type GeoJSONSource,
|
||||||
|
type GeoJSONSourceSpecification,
|
||||||
|
type LayerSpecification,
|
||||||
|
} from 'maplibre-gl';
|
||||||
|
import type { ZoneId } from '../../../entities/zone/model/meta';
|
||||||
|
import { ZONE_META } from '../../../entities/zone/model/meta';
|
||||||
|
import type { ZonesGeoJson } from '../../../entities/zone/api/useZones';
|
||||||
|
import type { MapToggleState } from '../../../features/mapToggles/MapToggles';
|
||||||
|
import type { BaseMapId, MapProjectionId } from '../types';
|
||||||
|
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(
|
||||||
|
mapRef: MutableRefObject<maplibregl.Map | null>,
|
||||||
|
projectionBusyRef: MutableRefObject<boolean>,
|
||||||
|
reorderGlobeFeatureLayers: () => void,
|
||||||
|
opts: {
|
||||||
|
zones: ZonesGeoJson | null;
|
||||||
|
overlays: MapToggleState;
|
||||||
|
projection: MapProjectionId;
|
||||||
|
baseMap: BaseMapId;
|
||||||
|
hoveredZoneId: string | null;
|
||||||
|
mapSyncEpoch: number;
|
||||||
|
},
|
||||||
|
) {
|
||||||
|
const { zones, overlays, projection, baseMap, hoveredZoneId, mapSyncEpoch } = opts;
|
||||||
|
|
||||||
|
// globe용 간소화 데이터를 미리 캐싱 — ensure() 내 매번 재계산 방지
|
||||||
|
const simplifiedZones = useMemo(
|
||||||
|
() => (zones ? simplifyZonesForGlobe(zones) : null),
|
||||||
|
[zones],
|
||||||
|
);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
const map = mapRef.current;
|
||||||
|
if (!map) return;
|
||||||
|
|
||||||
|
const srcId = 'zones-src';
|
||||||
|
const fillId = 'zones-fill';
|
||||||
|
const lineId = 'zones-line';
|
||||||
|
const labelId = 'zones-label';
|
||||||
|
|
||||||
|
const zoneColorExpr: unknown[] = ['match', ['get', 'zoneId']];
|
||||||
|
for (const k of Object.keys(ZONE_META) as ZoneId[]) {
|
||||||
|
zoneColorExpr.push(k, ZONE_META[k].color);
|
||||||
|
}
|
||||||
|
zoneColorExpr.push('#3B82F6');
|
||||||
|
const zoneLabelExpr: unknown[] = ['match', ['to-string', ['coalesce', ['get', 'zoneId'], '']]];
|
||||||
|
for (const k of Object.keys(ZONE_META) as ZoneId[]) {
|
||||||
|
zoneLabelExpr.push(k, ZONE_META[k].name);
|
||||||
|
}
|
||||||
|
zoneLabelExpr.push(['coalesce', ['get', 'zoneName'], ['get', 'zoneLabel'], ['get', 'NAME'], '수역']);
|
||||||
|
|
||||||
|
const ensure = () => {
|
||||||
|
// 소스 데이터 간소화 — projectionBusy 중에도 실행해야 함
|
||||||
|
// globe 전환 시 projectionBusy 가드 뒤에서만 실행하면 MapLibre가
|
||||||
|
// 원본(2100+ vertex) 데이터로 globe tessellation → 73,000+ vertex → 노란 막대
|
||||||
|
const sourceData = projection === 'globe' ? simplifiedZones : zones;
|
||||||
|
if (sourceData) {
|
||||||
|
try {
|
||||||
|
const existing = map.getSource(srcId) as GeoJSONSource | undefined;
|
||||||
|
if (existing) existing.setData(sourceData);
|
||||||
|
} catch { /* ignore — source may not exist yet */ }
|
||||||
|
}
|
||||||
|
|
||||||
|
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 (!map.isStyleLoaded()) return;
|
||||||
|
|
||||||
|
try {
|
||||||
|
// 소스가 아직 없으면 생성 (setData는 위에서 이미 처리됨)
|
||||||
|
if (!map.getSource(srcId)) {
|
||||||
|
const data = projection === 'globe' ? simplifiedZones ?? zones : zones;
|
||||||
|
map.addSource(srcId, { type: 'geojson', data: data! } as GeoJSONSourceSpecification);
|
||||||
|
}
|
||||||
|
|
||||||
|
const style = map.getStyle();
|
||||||
|
const styleLayers = style && Array.isArray(style.layers) ? style.layers : [];
|
||||||
|
const firstSymbol = styleLayers.find((l) => (l as { type?: string } | undefined)?.type === 'symbol') as
|
||||||
|
| { id?: string }
|
||||||
|
| undefined;
|
||||||
|
const before = map.getLayer('deck-globe')
|
||||||
|
? 'deck-globe'
|
||||||
|
: map.getLayer('ships')
|
||||||
|
? 'ships'
|
||||||
|
: map.getLayer('seamark')
|
||||||
|
? 'seamark'
|
||||||
|
: firstSymbol?.id;
|
||||||
|
|
||||||
|
const zoneMatchExpr =
|
||||||
|
hoveredZoneId !== null
|
||||||
|
? (['==', ['to-string', ['coalesce', ['get', 'zoneId'], '']], hoveredZoneId] as unknown[])
|
||||||
|
: false;
|
||||||
|
const zoneLineWidthExpr = hoveredZoneId
|
||||||
|
? ([
|
||||||
|
'interpolate',
|
||||||
|
['linear'],
|
||||||
|
['zoom'],
|
||||||
|
4,
|
||||||
|
['case', zoneMatchExpr, 1.6, 0.8],
|
||||||
|
10,
|
||||||
|
['case', zoneMatchExpr, 2.0, 1.4],
|
||||||
|
14,
|
||||||
|
['case', zoneMatchExpr, 2.8, 2.1],
|
||||||
|
] as unknown as never)
|
||||||
|
: (['interpolate', ['linear'], ['zoom'], 4, 0.8, 10, 1.4, 14, 2.1] as never);
|
||||||
|
|
||||||
|
if (map.getLayer(fillId)) {
|
||||||
|
try {
|
||||||
|
map.setPaintProperty(
|
||||||
|
fillId,
|
||||||
|
'fill-opacity',
|
||||||
|
hoveredZoneId ? (['case', zoneMatchExpr, 0.24, 0.1] as unknown as number) : 0.12,
|
||||||
|
);
|
||||||
|
} catch {
|
||||||
|
// ignore
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (map.getLayer(lineId)) {
|
||||||
|
try {
|
||||||
|
map.setPaintProperty(
|
||||||
|
lineId,
|
||||||
|
'line-color',
|
||||||
|
hoveredZoneId
|
||||||
|
? (['case', zoneMatchExpr, 'rgba(125,211,252,0.98)', zoneColorExpr as never] as never)
|
||||||
|
: (zoneColorExpr as never),
|
||||||
|
);
|
||||||
|
} catch {
|
||||||
|
// ignore
|
||||||
|
}
|
||||||
|
try {
|
||||||
|
map.setPaintProperty(lineId, 'line-opacity', hoveredZoneId ? (['case', zoneMatchExpr, 1, 0.85] as never) : 0.85);
|
||||||
|
} catch {
|
||||||
|
// ignore
|
||||||
|
}
|
||||||
|
try {
|
||||||
|
map.setPaintProperty(lineId, 'line-width', zoneLineWidthExpr);
|
||||||
|
} catch {
|
||||||
|
// ignore
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!map.getLayer(fillId)) {
|
||||||
|
map.addLayer(
|
||||||
|
{
|
||||||
|
id: fillId,
|
||||||
|
type: 'fill',
|
||||||
|
source: srcId,
|
||||||
|
paint: {
|
||||||
|
'fill-color': zoneColorExpr as never,
|
||||||
|
'fill-opacity': hoveredZoneId
|
||||||
|
? ([
|
||||||
|
'case',
|
||||||
|
zoneMatchExpr,
|
||||||
|
0.24,
|
||||||
|
0.1,
|
||||||
|
] as unknown as number)
|
||||||
|
: 0.12,
|
||||||
|
},
|
||||||
|
layout: { visibility },
|
||||||
|
} as unknown as LayerSpecification,
|
||||||
|
before,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!map.getLayer(lineId)) {
|
||||||
|
map.addLayer(
|
||||||
|
{
|
||||||
|
id: lineId,
|
||||||
|
type: 'line',
|
||||||
|
source: srcId,
|
||||||
|
paint: {
|
||||||
|
'line-color': hoveredZoneId
|
||||||
|
? (['case', zoneMatchExpr, 'rgba(125,211,252,0.98)', zoneColorExpr as never] as never)
|
||||||
|
: (zoneColorExpr as never),
|
||||||
|
'line-opacity': hoveredZoneId
|
||||||
|
? (['case', zoneMatchExpr, 1, 0.85] as never)
|
||||||
|
: 0.85,
|
||||||
|
'line-width': zoneLineWidthExpr,
|
||||||
|
},
|
||||||
|
layout: { visibility },
|
||||||
|
} as unknown as LayerSpecification,
|
||||||
|
before,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!map.getLayer(labelId)) {
|
||||||
|
map.addLayer(
|
||||||
|
{
|
||||||
|
id: labelId,
|
||||||
|
type: 'symbol',
|
||||||
|
source: srcId,
|
||||||
|
layout: {
|
||||||
|
visibility,
|
||||||
|
'symbol-placement': 'point',
|
||||||
|
'text-field': zoneLabelExpr as never,
|
||||||
|
'text-size': 11,
|
||||||
|
'text-font': ['Noto Sans Regular', 'Open Sans Regular'],
|
||||||
|
'text-anchor': 'top',
|
||||||
|
'text-offset': [0, 0.35],
|
||||||
|
'text-allow-overlap': false,
|
||||||
|
'text-ignore-placement': false,
|
||||||
|
},
|
||||||
|
paint: {
|
||||||
|
'text-color': '#dbeafe',
|
||||||
|
'text-halo-color': 'rgba(2,6,23,0.85)',
|
||||||
|
'text-halo-width': 1.2,
|
||||||
|
'text-halo-blur': 0.8,
|
||||||
|
},
|
||||||
|
} as unknown as LayerSpecification,
|
||||||
|
undefined,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
} catch (e) {
|
||||||
|
console.warn('Zones layer setup failed:', e);
|
||||||
|
} finally {
|
||||||
|
reorderGlobeFeatureLayers();
|
||||||
|
kickRepaint(map);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const stop = onMapStyleReady(map, ensure);
|
||||||
|
return () => {
|
||||||
|
stop();
|
||||||
|
};
|
||||||
|
}, [zones, simplifiedZones, overlays.zones, projection, baseMap, hoveredZoneId, mapSyncEpoch, reorderGlobeFeatureLayers]);
|
||||||
|
}
|
||||||
397
apps/web/src/widgets/map3d/layers/bathymetry.ts
Normal file
397
apps/web/src/widgets/map3d/layers/bathymetry.ts
Normal file
@ -0,0 +1,397 @@
|
|||||||
|
import maplibregl, {
|
||||||
|
type LayerSpecification,
|
||||||
|
type StyleSpecification,
|
||||||
|
type VectorSourceSpecification,
|
||||||
|
} from 'maplibre-gl';
|
||||||
|
import type { BaseMapId, BathyZoomRange, MapProjectionId } from '../types';
|
||||||
|
import { getLayerId, getMapTilerKey } from '../lib/mapCore';
|
||||||
|
|
||||||
|
export const SHALLOW_WATER_FILL_DEFAULT = '#14606e';
|
||||||
|
export const SHALLOW_WATER_LINE_DEFAULT = '#114f5c';
|
||||||
|
|
||||||
|
const BATHY_ZOOM_RANGES: BathyZoomRange[] = [
|
||||||
|
{ id: 'bathymetry-fill', mercator: [3, 24], globe: [3, 24] },
|
||||||
|
{ id: 'bathymetry-borders-major', mercator: [3, 7], globe: [3, 7] },
|
||||||
|
{ 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) {
|
||||||
|
const oceanSourceId = 'maptiler-ocean';
|
||||||
|
|
||||||
|
if (!style.sources) style.sources = {} as StyleSpecification['sources'];
|
||||||
|
if (!style.layers) style.layers = [];
|
||||||
|
|
||||||
|
if (!style.sources[oceanSourceId]) {
|
||||||
|
style.sources[oceanSourceId] = {
|
||||||
|
type: 'vector',
|
||||||
|
url: `https://api.maptiler.com/tiles/ocean/tiles.json?key=${encodeURIComponent(maptilerKey)}`,
|
||||||
|
} satisfies VectorSourceSpecification as unknown as StyleSpecification['sources'][string];
|
||||||
|
}
|
||||||
|
|
||||||
|
const depth = ['to-number', ['get', 'depth']] as unknown as number[];
|
||||||
|
const depthLabel = ['concat', ['to-string', ['*', depth, -1]], 'm'] as unknown as string[];
|
||||||
|
|
||||||
|
// 수심 색상: 전체 범위 (-8000m ~ 0m)
|
||||||
|
const bathyFillColor = [
|
||||||
|
'interpolate',
|
||||||
|
['linear'],
|
||||||
|
depth,
|
||||||
|
-8000,
|
||||||
|
'#010610',
|
||||||
|
-4000,
|
||||||
|
'#030c1c',
|
||||||
|
-2000,
|
||||||
|
'#041022',
|
||||||
|
-1000,
|
||||||
|
'#051529',
|
||||||
|
-500,
|
||||||
|
'#061a30',
|
||||||
|
-200,
|
||||||
|
'#071f36',
|
||||||
|
-100,
|
||||||
|
'#08263d',
|
||||||
|
-50,
|
||||||
|
'#0e3d5e',
|
||||||
|
-20,
|
||||||
|
'#145578',
|
||||||
|
-10,
|
||||||
|
'#1a6e8e',
|
||||||
|
0,
|
||||||
|
'#2097a6',
|
||||||
|
] 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 = {
|
||||||
|
id: 'bathymetry-fill',
|
||||||
|
type: 'fill',
|
||||||
|
source: oceanSourceId,
|
||||||
|
'source-layer': 'contour',
|
||||||
|
minzoom: 3,
|
||||||
|
maxzoom: 24,
|
||||||
|
paint: {
|
||||||
|
'fill-color': bathyFillColor,
|
||||||
|
'fill-opacity': ['interpolate', ['linear'], ['zoom'], 0, 0.9, 5, 0.88, 8, 0.84, 10, 0.78],
|
||||||
|
},
|
||||||
|
} as unknown as 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',
|
||||||
|
type: 'line',
|
||||||
|
source: oceanSourceId,
|
||||||
|
'source-layer': 'contour',
|
||||||
|
minzoom: 7,
|
||||||
|
maxzoom: 24,
|
||||||
|
paint: {
|
||||||
|
'line-color': 'rgba(255,255,255,0.06)',
|
||||||
|
'line-opacity': ['interpolate', ['linear'], ['zoom'], 7, 0.18, 12, 0.22],
|
||||||
|
'line-blur': ['interpolate', ['linear'], ['zoom'], 7, 0.3, 10, 0.2],
|
||||||
|
'line-width': ['interpolate', ['linear'], ['zoom'], 7, 0.35, 12, 0.6],
|
||||||
|
},
|
||||||
|
} as unknown as LayerSpecification;
|
||||||
|
|
||||||
|
// === Contour lines (contour_line) — 3-tier LOD ===
|
||||||
|
// z5-z7: 1000m, 2000m만
|
||||||
|
const bathyLinesCoarse: LayerSpecification = {
|
||||||
|
id: 'bathymetry-lines-coarse',
|
||||||
|
type: 'line',
|
||||||
|
source: oceanSourceId,
|
||||||
|
'source-layer': 'contour_line',
|
||||||
|
minzoom: 5,
|
||||||
|
maxzoom: 7,
|
||||||
|
filter: depthIn(DEPTHS_COARSE) as unknown as unknown[],
|
||||||
|
paint: {
|
||||||
|
'line-color': 'rgba(255,255,255,0.12)',
|
||||||
|
'line-opacity': ['interpolate', ['linear'], ['zoom'], 5, 0.15, 7, 0.22],
|
||||||
|
'line-blur': 0.5,
|
||||||
|
'line-width': ['interpolate', ['linear'], ['zoom'], 5, 0.4, 7, 0.6],
|
||||||
|
},
|
||||||
|
} as unknown as LayerSpecification;
|
||||||
|
|
||||||
|
// z7-z9: 100, 500, 1000, 2000, 4000m
|
||||||
|
const bathyLinesMajor: LayerSpecification = {
|
||||||
|
id: 'bathymetry-lines-major',
|
||||||
|
type: 'line',
|
||||||
|
source: oceanSourceId,
|
||||||
|
'source-layer': 'contour_line',
|
||||||
|
minzoom: 7,
|
||||||
|
maxzoom: 9,
|
||||||
|
filter: depthIn(DEPTHS_MEDIUM) as unknown as unknown[],
|
||||||
|
paint: {
|
||||||
|
'line-color': 'rgba(255,255,255,0.16)',
|
||||||
|
'line-opacity': ['interpolate', ['linear'], ['zoom'], 7, 0.22, 9, 0.28],
|
||||||
|
'line-blur': ['interpolate', ['linear'], ['zoom'], 7, 0.4, 9, 0.2],
|
||||||
|
'line-width': ['interpolate', ['linear'], ['zoom'], 7, 0.6, 9, 0.95],
|
||||||
|
},
|
||||||
|
} as unknown as LayerSpecification;
|
||||||
|
|
||||||
|
// z9+: 50~8000m (풀 디테일)
|
||||||
|
const bathyLinesDetail: LayerSpecification = {
|
||||||
|
id: 'bathymetry-lines-detail',
|
||||||
|
type: 'line',
|
||||||
|
source: oceanSourceId,
|
||||||
|
'source-layer': 'contour_line',
|
||||||
|
minzoom: 9,
|
||||||
|
maxzoom: 24,
|
||||||
|
filter: depthIn(DEPTHS_DETAIL) as unknown as unknown[],
|
||||||
|
paint: {
|
||||||
|
'line-color': 'rgba(255,255,255,0.16)',
|
||||||
|
'line-opacity': ['interpolate', ['linear'], ['zoom'], 9, 0.28, 12, 0.34],
|
||||||
|
'line-blur': ['interpolate', ['linear'], ['zoom'], 9, 0.2, 11, 0.15],
|
||||||
|
'line-width': ['interpolate', ['linear'], ['zoom'], 9, 0.95, 12, 1.3],
|
||||||
|
},
|
||||||
|
} 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 = {
|
||||||
|
id: 'bathymetry-labels',
|
||||||
|
type: 'symbol',
|
||||||
|
source: oceanSourceId,
|
||||||
|
'source-layer': 'contour_line',
|
||||||
|
minzoom: 9,
|
||||||
|
filter: depthIn(DEPTHS_MEDIUM) as unknown as unknown[],
|
||||||
|
layout: {
|
||||||
|
'symbol-placement': 'line',
|
||||||
|
'text-field': depthLabel,
|
||||||
|
'text-font': ['Noto Sans Regular', 'Open Sans Regular'],
|
||||||
|
'text-size': ['interpolate', ['linear'], ['zoom'], 9, 12, 11, 14, 13, 16],
|
||||||
|
'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;
|
||||||
|
|
||||||
|
const landformLabels: LayerSpecification = {
|
||||||
|
id: 'bathymetry-landforms',
|
||||||
|
type: 'symbol',
|
||||||
|
source: oceanSourceId,
|
||||||
|
'source-layer': 'landform',
|
||||||
|
minzoom: 6,
|
||||||
|
filter: ['has', 'name'] as unknown as unknown[],
|
||||||
|
layout: {
|
||||||
|
'text-field': ['get', 'name'] as unknown as unknown[],
|
||||||
|
'text-font': ['Noto Sans Italic', 'Noto Sans Regular', 'Open Sans Italic', 'Open Sans Regular'],
|
||||||
|
'text-size': ['interpolate', ['linear'], ['zoom'], 6, 10, 8, 12, 10, 13, 12, 14],
|
||||||
|
'text-allow-overlap': false,
|
||||||
|
'text-anchor': 'center',
|
||||||
|
'text-offset': [0, 0.0],
|
||||||
|
},
|
||||||
|
paint: {
|
||||||
|
'text-color': 'rgba(148,163,184,0.75)',
|
||||||
|
'text-halo-color': 'rgba(2,6,23,0.88)',
|
||||||
|
'text-halo-width': 1.2,
|
||||||
|
'text-halo-blur': 0.6,
|
||||||
|
},
|
||||||
|
} as unknown as LayerSpecification;
|
||||||
|
|
||||||
|
const layers = Array.isArray(style.layers) ? (style.layers as unknown as LayerSpecification[]) : [];
|
||||||
|
if (!Array.isArray(style.layers)) {
|
||||||
|
style.layers = layers as unknown as StyleSpecification['layers'];
|
||||||
|
}
|
||||||
|
|
||||||
|
// Brighten base-map water fills so shallow coasts, rivers & lakes blend naturally
|
||||||
|
// with the bathymetry gradient instead of appearing as near-black voids.
|
||||||
|
const waterRegex = /(water|sea|ocean|river|lake|coast|bay)/i;
|
||||||
|
const SHALLOW_WATER_FILL = SHALLOW_WATER_FILL_DEFAULT;
|
||||||
|
const SHALLOW_WATER_LINE = SHALLOW_WATER_LINE_DEFAULT;
|
||||||
|
for (const layer of layers) {
|
||||||
|
const id = getLayerId(layer);
|
||||||
|
if (!id) continue;
|
||||||
|
const spec = layer as Record<string, unknown>;
|
||||||
|
const sourceLayer = String(spec['source-layer'] ?? '').toLowerCase();
|
||||||
|
const layerType = String(spec.type ?? '');
|
||||||
|
const isWater = waterRegex.test(id) || waterRegex.test(sourceLayer);
|
||||||
|
if (!isWater) continue;
|
||||||
|
|
||||||
|
const paint = (spec.paint ?? {}) as Record<string, unknown>;
|
||||||
|
if (layerType === 'fill') {
|
||||||
|
paint['fill-color'] = SHALLOW_WATER_FILL;
|
||||||
|
spec.paint = paint;
|
||||||
|
} else if (layerType === 'line') {
|
||||||
|
paint['line-color'] = SHALLOW_WATER_LINE;
|
||||||
|
spec.paint = paint;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const symbolIndex = layers.findIndex((l) => (l as { type?: unknown } | null)?.type === 'symbol');
|
||||||
|
const insertAt = symbolIndex >= 0 ? symbolIndex : layers.length;
|
||||||
|
|
||||||
|
const existingIds = new Set<string>();
|
||||||
|
for (const layer of layers) {
|
||||||
|
const id = getLayerId(layer);
|
||||||
|
if (id) existingIds.add(id);
|
||||||
|
}
|
||||||
|
|
||||||
|
const toInsert = [
|
||||||
|
bathyFill,
|
||||||
|
bathyBordersMajor,
|
||||||
|
bathyBorders,
|
||||||
|
bathyLinesCoarse,
|
||||||
|
bathyLinesMajor,
|
||||||
|
bathyLinesDetail,
|
||||||
|
bathyLabelsCoarse,
|
||||||
|
bathyLabels,
|
||||||
|
landformLabels,
|
||||||
|
].filter((l) => !existingIds.has(l.id));
|
||||||
|
if (toInsert.length > 0) layers.splice(insertAt, 0, ...toInsert);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function applyBathymetryZoomProfile(map: maplibregl.Map, baseMap: BaseMapId, projection: MapProjectionId) {
|
||||||
|
if (!map || !map.isStyleLoaded()) return;
|
||||||
|
if (baseMap !== 'enhanced') return;
|
||||||
|
const isGlobe = projection === 'globe';
|
||||||
|
|
||||||
|
for (const range of BATHY_ZOOM_RANGES) {
|
||||||
|
if (!map.getLayer(range.id)) continue;
|
||||||
|
const [minzoom, maxzoom] = isGlobe ? range.globe : range.mercator;
|
||||||
|
try {
|
||||||
|
map.setLayoutProperty(range.id, 'visibility', 'visible');
|
||||||
|
} catch {
|
||||||
|
// ignore
|
||||||
|
}
|
||||||
|
try {
|
||||||
|
map.setLayerZoomRange(range.id, minzoom, maxzoom);
|
||||||
|
} catch {
|
||||||
|
// ignore
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
}
|
||||||
|
|
||||||
|
function applyKoreanLabels(style: StyleSpecification) {
|
||||||
|
if (!style.layers) return;
|
||||||
|
const koTextField = ['coalesce', ['get', 'name:ko'], ['get', 'name']];
|
||||||
|
for (const layer of style.layers as unknown as LayerSpecification[]) {
|
||||||
|
if ((layer as { type?: string }).type !== 'symbol') continue;
|
||||||
|
const layout = (layer as Record<string, unknown>).layout as
|
||||||
|
| Record<string, unknown>
|
||||||
|
| undefined;
|
||||||
|
if (!layout?.['text-field']) continue;
|
||||||
|
layout['text-field'] = koTextField;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function resolveInitialMapStyle(signal: AbortSignal): Promise<string | StyleSpecification> {
|
||||||
|
const key = getMapTilerKey();
|
||||||
|
if (!key) return '/map/styles/osm-seamark.json';
|
||||||
|
|
||||||
|
const baseMapId = (import.meta.env.VITE_MAPTILER_BASE_MAP_ID || 'dataviz-dark').trim();
|
||||||
|
const styleUrl = `https://api.maptiler.com/maps/${encodeURIComponent(baseMapId)}/style.json?key=${encodeURIComponent(key)}`;
|
||||||
|
|
||||||
|
const res = await fetch(styleUrl, { signal, headers: { accept: 'application/json' } });
|
||||||
|
if (!res.ok) throw new Error(`MapTiler style fetch failed: ${res.status} ${res.statusText}`);
|
||||||
|
const json = (await res.json()) as StyleSpecification;
|
||||||
|
applyLandLayerLOD(json);
|
||||||
|
applyKoreanLabels(json);
|
||||||
|
injectOceanBathymetryLayers(json, key);
|
||||||
|
return json;
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function resolveMapStyle(baseMap: BaseMapId, signal: AbortSignal): Promise<string | StyleSpecification> {
|
||||||
|
// 레거시 베이스맵 비활성 — 향후 위성/라이트 테마 등 추가 시 재활용
|
||||||
|
// if (baseMap === 'legacy') return '/map/styles/carto-dark.json';
|
||||||
|
void baseMap;
|
||||||
|
return resolveInitialMapStyle(signal);
|
||||||
|
}
|
||||||
27
apps/web/src/widgets/map3d/layers/seamark.ts
Normal file
27
apps/web/src/widgets/map3d/layers/seamark.ts
Normal file
@ -0,0 +1,27 @@
|
|||||||
|
import maplibregl, { type LayerSpecification } from 'maplibre-gl';
|
||||||
|
|
||||||
|
export function ensureSeamarkOverlay(map: maplibregl.Map, beforeLayerId?: string) {
|
||||||
|
const srcId = 'seamark';
|
||||||
|
const layerId = 'seamark';
|
||||||
|
|
||||||
|
if (!map.getSource(srcId)) {
|
||||||
|
map.addSource(srcId, {
|
||||||
|
type: 'raster',
|
||||||
|
tiles: ['https://tiles.openseamap.org/seamark/{z}/{x}/{y}.png'],
|
||||||
|
tileSize: 256,
|
||||||
|
attribution: '© OpenSeaMap contributors',
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!map.getLayer(layerId)) {
|
||||||
|
const layer: LayerSpecification = {
|
||||||
|
id: layerId,
|
||||||
|
type: 'raster',
|
||||||
|
source: srcId,
|
||||||
|
paint: { 'raster-opacity': 0.85 },
|
||||||
|
} as unknown as LayerSpecification;
|
||||||
|
|
||||||
|
const before = beforeLayerId && map.getLayer(beforeLayerId) ? beforeLayerId : undefined;
|
||||||
|
map.addLayer(layer, before);
|
||||||
|
}
|
||||||
|
}
|
||||||
31
apps/web/src/widgets/map3d/lib/dashifyLine.ts
Normal file
31
apps/web/src/widgets/map3d/lib/dashifyLine.ts
Normal file
@ -0,0 +1,31 @@
|
|||||||
|
import type { DashSeg } from '../types';
|
||||||
|
|
||||||
|
export function dashifyLine(
|
||||||
|
from: [number, number],
|
||||||
|
to: [number, number],
|
||||||
|
suspicious: boolean,
|
||||||
|
distanceNm?: number,
|
||||||
|
fromMmsi?: number,
|
||||||
|
toMmsi?: number,
|
||||||
|
): DashSeg[] {
|
||||||
|
const segs: DashSeg[] = [];
|
||||||
|
const steps = 14;
|
||||||
|
for (let i = 0; i < steps; i++) {
|
||||||
|
if (i % 2 === 1) continue;
|
||||||
|
const a0 = i / steps;
|
||||||
|
const a1 = (i + 1) / steps;
|
||||||
|
const lon0 = from[0] + (to[0] - from[0]) * a0;
|
||||||
|
const lat0 = from[1] + (to[1] - from[1]) * a0;
|
||||||
|
const lon1 = from[0] + (to[0] - from[0]) * a1;
|
||||||
|
const lat1 = from[1] + (to[1] - from[1]) * a1;
|
||||||
|
segs.push({
|
||||||
|
from: [lon0, lat0],
|
||||||
|
to: [lon1, lat1],
|
||||||
|
suspicious,
|
||||||
|
distanceNm,
|
||||||
|
fromMmsi,
|
||||||
|
toMmsi,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
return segs;
|
||||||
|
}
|
||||||
19
apps/web/src/widgets/map3d/lib/featureIds.ts
Normal file
19
apps/web/src/widgets/map3d/lib/featureIds.ts
Normal file
@ -0,0 +1,19 @@
|
|||||||
|
export function makeOrderedPairKey(a: number, b: number) {
|
||||||
|
const left = Math.trunc(Math.min(a, b));
|
||||||
|
const right = Math.trunc(Math.max(a, b));
|
||||||
|
return `${left}-${right}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function makePairLinkFeatureId(a: number, b: number, suffix?: string) {
|
||||||
|
const pair = makeOrderedPairKey(a, b);
|
||||||
|
return suffix ? `pair-${pair}-${suffix}` : `pair-${pair}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function makeFcSegmentFeatureId(a: number, b: number, segmentIndex: number) {
|
||||||
|
const pair = makeOrderedPairKey(a, b);
|
||||||
|
return `fc-${pair}-${segmentIndex}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function makeFleetCircleFeatureId(ownerKey: string) {
|
||||||
|
return `fleet-${ownerKey}`;
|
||||||
|
}
|
||||||
61
apps/web/src/widgets/map3d/lib/geometry.ts
Normal file
61
apps/web/src/widgets/map3d/lib/geometry.ts
Normal file
@ -0,0 +1,61 @@
|
|||||||
|
import { DEG2RAD, RAD2DEG, EARTH_RADIUS_M } from '../constants';
|
||||||
|
|
||||||
|
export const clampNumber = (value: number, minValue: number, maxValue: number) =>
|
||||||
|
Math.max(minValue, Math.min(maxValue, value));
|
||||||
|
|
||||||
|
export function wrapLonDeg(lon: number) {
|
||||||
|
const v = ((lon + 180) % 360 + 360) % 360;
|
||||||
|
return v - 180;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function destinationPointLngLat(
|
||||||
|
from: [number, number],
|
||||||
|
bearingDeg: number,
|
||||||
|
distanceMeters: number,
|
||||||
|
): [number, number] {
|
||||||
|
const [lonDeg, latDeg] = from;
|
||||||
|
const lat1 = latDeg * DEG2RAD;
|
||||||
|
const lon1 = lonDeg * DEG2RAD;
|
||||||
|
const brng = bearingDeg * DEG2RAD;
|
||||||
|
const dr = Math.max(0, distanceMeters) / EARTH_RADIUS_M;
|
||||||
|
if (!Number.isFinite(dr) || dr === 0) return [lonDeg, latDeg];
|
||||||
|
|
||||||
|
const sinLat1 = Math.sin(lat1);
|
||||||
|
const cosLat1 = Math.cos(lat1);
|
||||||
|
const sinDr = Math.sin(dr);
|
||||||
|
const cosDr = Math.cos(dr);
|
||||||
|
|
||||||
|
const lat2 = Math.asin(sinLat1 * cosDr + cosLat1 * sinDr * Math.cos(brng));
|
||||||
|
const lon2 =
|
||||||
|
lon1 +
|
||||||
|
Math.atan2(
|
||||||
|
Math.sin(brng) * sinDr * cosLat1,
|
||||||
|
cosDr - sinLat1 * Math.sin(lat2),
|
||||||
|
);
|
||||||
|
|
||||||
|
const outLon = wrapLonDeg(lon2 * RAD2DEG);
|
||||||
|
const outLat = clampNumber(lat2 * RAD2DEG, -85.0, 85.0);
|
||||||
|
return [outLon, outLat];
|
||||||
|
}
|
||||||
|
|
||||||
|
export function circleRingLngLat(center: [number, number], radiusMeters: number, steps = 36): [number, number][] {
|
||||||
|
// 반경이 지구 둘레의 1/4 (≈10,000km)을 넘으면 클램핑
|
||||||
|
const r = clampNumber(radiusMeters, 0, EARTH_RADIUS_M * Math.PI * 0.5);
|
||||||
|
|
||||||
|
const ring: [number, number][] = [];
|
||||||
|
for (let i = 0; i <= steps; i++) {
|
||||||
|
const a = (i / steps) * Math.PI * 2;
|
||||||
|
const pt = destinationPointLngLat(center, a * RAD2DEG, r);
|
||||||
|
ring.push(pt);
|
||||||
|
}
|
||||||
|
// 고리 닫기 보정
|
||||||
|
if (ring.length > 1) {
|
||||||
|
ring[ring.length - 1] = ring[0];
|
||||||
|
}
|
||||||
|
return ring;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function normalizeAngleDeg(value: number, offset = 0): number {
|
||||||
|
const v = value + offset;
|
||||||
|
return ((v % 360) + 360) % 360;
|
||||||
|
}
|
||||||
76
apps/web/src/widgets/map3d/lib/globeShipIcon.ts
Normal file
76
apps/web/src/widgets/map3d/lib/globeShipIcon.ts
Normal file
@ -0,0 +1,76 @@
|
|||||||
|
import maplibregl from 'maplibre-gl';
|
||||||
|
|
||||||
|
export function buildFallbackGlobeShipIcon() {
|
||||||
|
const size = 96;
|
||||||
|
const canvas = document.createElement('canvas');
|
||||||
|
canvas.width = size;
|
||||||
|
canvas.height = size;
|
||||||
|
const ctx = canvas.getContext('2d');
|
||||||
|
if (!ctx) return null;
|
||||||
|
|
||||||
|
ctx.clearRect(0, 0, size, size);
|
||||||
|
ctx.fillStyle = 'rgba(255,255,255,1)';
|
||||||
|
ctx.beginPath();
|
||||||
|
ctx.moveTo(size / 2, 6);
|
||||||
|
ctx.lineTo(size / 2 - 14, 24);
|
||||||
|
ctx.lineTo(size / 2 - 18, 58);
|
||||||
|
ctx.lineTo(size / 2 - 10, 88);
|
||||||
|
ctx.lineTo(size / 2 + 10, 88);
|
||||||
|
ctx.lineTo(size / 2 + 18, 58);
|
||||||
|
ctx.lineTo(size / 2 + 14, 24);
|
||||||
|
ctx.closePath();
|
||||||
|
ctx.fill();
|
||||||
|
|
||||||
|
ctx.fillRect(size / 2 - 8, 34, 16, 18);
|
||||||
|
|
||||||
|
return ctx.getImageData(0, 0, size, size);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function buildFallbackGlobeAnchoredShipIcon() {
|
||||||
|
const baseImage = buildFallbackGlobeShipIcon();
|
||||||
|
if (!baseImage) return null;
|
||||||
|
|
||||||
|
const size = baseImage.width;
|
||||||
|
const canvas = document.createElement('canvas');
|
||||||
|
canvas.width = size;
|
||||||
|
canvas.height = size;
|
||||||
|
const ctx = canvas.getContext('2d');
|
||||||
|
if (!ctx) return null;
|
||||||
|
|
||||||
|
ctx.putImageData(baseImage, 0, 0);
|
||||||
|
|
||||||
|
ctx.strokeStyle = 'rgba(248,250,252,1)';
|
||||||
|
ctx.lineWidth = 5;
|
||||||
|
ctx.lineCap = 'round';
|
||||||
|
ctx.beginPath();
|
||||||
|
const cx = size / 2;
|
||||||
|
ctx.moveTo(cx - 18, 76);
|
||||||
|
ctx.lineTo(cx + 18, 76);
|
||||||
|
ctx.moveTo(cx, 66);
|
||||||
|
ctx.lineTo(cx, 82);
|
||||||
|
ctx.moveTo(cx, 82);
|
||||||
|
ctx.arc(cx, 82, 7, 0, Math.PI * 2);
|
||||||
|
ctx.moveTo(cx, 82);
|
||||||
|
ctx.lineTo(cx, 88);
|
||||||
|
ctx.moveTo(cx - 9, 88);
|
||||||
|
ctx.lineTo(cx + 9, 88);
|
||||||
|
ctx.stroke();
|
||||||
|
|
||||||
|
return ctx.getImageData(0, 0, size, size);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function ensureFallbackShipImage(
|
||||||
|
map: maplibregl.Map,
|
||||||
|
imageId: string,
|
||||||
|
fallbackBuilder: () => ImageData | null = buildFallbackGlobeShipIcon,
|
||||||
|
) {
|
||||||
|
if (!map || map.hasImage(imageId)) return;
|
||||||
|
const image = fallbackBuilder();
|
||||||
|
if (!image) return;
|
||||||
|
|
||||||
|
try {
|
||||||
|
map.addImage(imageId, image, { pixelRatio: 2, sdf: true });
|
||||||
|
} catch {
|
||||||
|
// ignore
|
||||||
|
}
|
||||||
|
}
|
||||||
141
apps/web/src/widgets/map3d/lib/layerHelpers.ts
Normal file
141
apps/web/src/widgets/map3d/lib/layerHelpers.ts
Normal file
@ -0,0 +1,141 @@
|
|||||||
|
import maplibregl, {
|
||||||
|
type GeoJSONSourceSpecification,
|
||||||
|
type LayerSpecification,
|
||||||
|
} from 'maplibre-gl';
|
||||||
|
|
||||||
|
export function removeLayerIfExists(map: maplibregl.Map, layerId: string | null | undefined) {
|
||||||
|
if (!layerId) return;
|
||||||
|
try {
|
||||||
|
if (map.getLayer(layerId)) {
|
||||||
|
map.removeLayer(layerId);
|
||||||
|
}
|
||||||
|
} catch {
|
||||||
|
// ignore
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export function removeSourceIfExists(map: maplibregl.Map, sourceId: string) {
|
||||||
|
try {
|
||||||
|
if (map.getSource(sourceId)) {
|
||||||
|
map.removeSource(sourceId);
|
||||||
|
}
|
||||||
|
} catch {
|
||||||
|
// ignore
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Ship 레이어/소스는 useGlobeShips에서 visibility 토글로 관리 (재생성 비용 회피)
|
||||||
|
const GLOBE_NATIVE_LAYER_IDS = [
|
||||||
|
'pair-lines-ml',
|
||||||
|
'fc-lines-ml',
|
||||||
|
'fleet-circles-ml',
|
||||||
|
'pair-range-ml',
|
||||||
|
'subcables-hitarea',
|
||||||
|
'subcables-casing',
|
||||||
|
'subcables-line',
|
||||||
|
'subcables-glow',
|
||||||
|
'subcables-points',
|
||||||
|
'subcables-label',
|
||||||
|
'vessel-track-line',
|
||||||
|
'vessel-track-line-hitarea',
|
||||||
|
'vessel-track-arrow',
|
||||||
|
'vessel-track-pts',
|
||||||
|
'vessel-track-pts-highlight',
|
||||||
|
'deck-globe',
|
||||||
|
];
|
||||||
|
|
||||||
|
const GLOBE_NATIVE_SOURCE_IDS = [
|
||||||
|
'pair-lines-ml-src',
|
||||||
|
'fc-lines-ml-src',
|
||||||
|
'fleet-circles-ml-src',
|
||||||
|
'pair-range-ml-src',
|
||||||
|
'subcables-src',
|
||||||
|
'subcables-pts-src',
|
||||||
|
'vessel-track-line-src',
|
||||||
|
'vessel-track-pts-src',
|
||||||
|
];
|
||||||
|
|
||||||
|
export function clearGlobeNativeLayers(map: maplibregl.Map) {
|
||||||
|
for (const id of GLOBE_NATIVE_LAYER_IDS) {
|
||||||
|
removeLayerIfExists(map, id);
|
||||||
|
}
|
||||||
|
for (const id of GLOBE_NATIVE_SOURCE_IDS) {
|
||||||
|
removeSourceIfExists(map, id);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export function ensureGeoJsonSource(
|
||||||
|
map: maplibregl.Map,
|
||||||
|
sourceId: string,
|
||||||
|
data: GeoJSON.GeoJSON,
|
||||||
|
options?: Partial<Omit<GeoJSONSourceSpecification, 'type' | 'data'>>,
|
||||||
|
) {
|
||||||
|
const existing = map.getSource(sourceId);
|
||||||
|
if (existing) {
|
||||||
|
(existing as maplibregl.GeoJSONSource).setData(data);
|
||||||
|
} else {
|
||||||
|
map.addSource(sourceId, {
|
||||||
|
type: 'geojson',
|
||||||
|
data,
|
||||||
|
...options,
|
||||||
|
} satisfies GeoJSONSourceSpecification);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export function ensureLayer(
|
||||||
|
map: maplibregl.Map,
|
||||||
|
spec: LayerSpecification,
|
||||||
|
options?: { before?: string },
|
||||||
|
) {
|
||||||
|
if (map.getLayer(spec.id)) return;
|
||||||
|
const before = options?.before && map.getLayer(options.before) ? options.before : undefined;
|
||||||
|
map.addLayer(spec, before);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function setLayerVisibility(map: maplibregl.Map, layerId: string, visible: boolean) {
|
||||||
|
if (!map.getLayer(layerId)) return;
|
||||||
|
try {
|
||||||
|
map.setLayoutProperty(layerId, 'visibility', visible ? 'visible' : 'none');
|
||||||
|
} catch {
|
||||||
|
// ignore
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 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(
|
||||||
|
map: maplibregl.Map,
|
||||||
|
layerIds: string[],
|
||||||
|
sourceIds: string[],
|
||||||
|
) {
|
||||||
|
requestAnimationFrame(() => {
|
||||||
|
for (const id of layerIds) {
|
||||||
|
try {
|
||||||
|
if (map.getLayer(id)) map.removeLayer(id);
|
||||||
|
} catch {
|
||||||
|
// ignore
|
||||||
|
}
|
||||||
|
}
|
||||||
|
for (const id of sourceIds) {
|
||||||
|
try {
|
||||||
|
if (map.getSource(id)) map.removeSource(id);
|
||||||
|
} catch {
|
||||||
|
// ignore
|
||||||
|
}
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
137
apps/web/src/widgets/map3d/lib/mapCore.ts
Normal file
137
apps/web/src/widgets/map3d/lib/mapCore.ts
Normal file
@ -0,0 +1,137 @@
|
|||||||
|
import maplibregl from 'maplibre-gl';
|
||||||
|
import type { MapProjectionId } from '../types';
|
||||||
|
|
||||||
|
export function kickRepaint(map: maplibregl.Map | null) {
|
||||||
|
if (!map) return;
|
||||||
|
try {
|
||||||
|
if (map.isStyleLoaded()) map.triggerRepaint();
|
||||||
|
} catch {
|
||||||
|
// ignore
|
||||||
|
}
|
||||||
|
try {
|
||||||
|
requestAnimationFrame(() => {
|
||||||
|
try {
|
||||||
|
if (map.isStyleLoaded()) map.triggerRepaint();
|
||||||
|
} catch {
|
||||||
|
// ignore
|
||||||
|
}
|
||||||
|
});
|
||||||
|
requestAnimationFrame(() => {
|
||||||
|
try {
|
||||||
|
if (map.isStyleLoaded()) map.triggerRepaint();
|
||||||
|
} catch {
|
||||||
|
// ignore
|
||||||
|
}
|
||||||
|
});
|
||||||
|
} catch {
|
||||||
|
// ignore (e.g., non-browser env)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export function onMapStyleReady(map: maplibregl.Map | null, callback: () => void) {
|
||||||
|
if (!map) {
|
||||||
|
return () => {
|
||||||
|
// noop
|
||||||
|
};
|
||||||
|
}
|
||||||
|
if (map.isStyleLoaded()) {
|
||||||
|
callback();
|
||||||
|
return () => {
|
||||||
|
// noop
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
let fired = false;
|
||||||
|
const runOnce = () => {
|
||||||
|
if (!map || fired || !map.isStyleLoaded()) return;
|
||||||
|
fired = true;
|
||||||
|
callback();
|
||||||
|
try {
|
||||||
|
map.off('style.load', runOnce);
|
||||||
|
map.off('styledata', runOnce);
|
||||||
|
map.off('idle', runOnce);
|
||||||
|
} catch {
|
||||||
|
// ignore
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
map.on('style.load', runOnce);
|
||||||
|
map.on('styledata', runOnce);
|
||||||
|
map.on('idle', runOnce);
|
||||||
|
|
||||||
|
return () => {
|
||||||
|
if (fired) return;
|
||||||
|
fired = true;
|
||||||
|
try {
|
||||||
|
if (!map) return;
|
||||||
|
map.off('style.load', runOnce);
|
||||||
|
map.off('styledata', runOnce);
|
||||||
|
map.off('idle', runOnce);
|
||||||
|
} catch {
|
||||||
|
// ignore
|
||||||
|
}
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
export function extractProjectionType(map: maplibregl.Map): MapProjectionId | undefined {
|
||||||
|
const projection = map.getProjection?.();
|
||||||
|
if (!projection || typeof projection !== 'object') return undefined;
|
||||||
|
|
||||||
|
const rawType = (projection as { type?: unknown; name?: unknown }).type ?? (projection as { type?: unknown; name?: unknown }).name;
|
||||||
|
if (rawType === 'globe') return 'globe';
|
||||||
|
if (rawType === 'mercator') return 'mercator';
|
||||||
|
return undefined;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function getMapTilerKey(): string | null {
|
||||||
|
const k = import.meta.env.VITE_MAPTILER_KEY;
|
||||||
|
if (typeof k !== 'string') return null;
|
||||||
|
const v = k.trim();
|
||||||
|
return v ? v : null;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function getLayerId(value: unknown): string | null {
|
||||||
|
if (!value || typeof value !== 'object') return null;
|
||||||
|
const candidate = (value as { id?: unknown }).id;
|
||||||
|
return typeof candidate === 'string' ? candidate : null;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function sanitizeDeckLayerList(value: unknown): unknown[] {
|
||||||
|
if (!Array.isArray(value)) return [];
|
||||||
|
const seen = new Set<string>();
|
||||||
|
const out: unknown[] = [];
|
||||||
|
let dropped = 0;
|
||||||
|
|
||||||
|
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);
|
||||||
|
if (!layerId) {
|
||||||
|
dropped += 1;
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
if (seen.has(layerId)) {
|
||||||
|
dropped += 1;
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
seen.add(layerId);
|
||||||
|
out.push(layer);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (dropped > 0 && import.meta.env.DEV) {
|
||||||
|
console.warn(`Sanitized deck layer list: dropped ${dropped} invalid/duplicate entries`);
|
||||||
|
}
|
||||||
|
|
||||||
|
return out;
|
||||||
|
}
|
||||||
68
apps/web/src/widgets/map3d/lib/mlExpressions.ts
Normal file
68
apps/web/src/widgets/map3d/lib/mlExpressions.ts
Normal file
@ -0,0 +1,68 @@
|
|||||||
|
export function makeMmsiPairHighlightExpr(aField: string, bField: string, hoveredMmsiList: number[]) {
|
||||||
|
if (!Array.isArray(hoveredMmsiList) || hoveredMmsiList.length < 2) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
const inA = ['in', ['to-number', ['get', aField]], ['literal', hoveredMmsiList]] as unknown[];
|
||||||
|
const inB = ['in', ['to-number', ['get', bField]], ['literal', hoveredMmsiList]] as unknown[];
|
||||||
|
return ['all', inA, inB] as unknown[];
|
||||||
|
}
|
||||||
|
|
||||||
|
export function makeMmsiAnyEndpointExpr(aField: string, bField: string, hoveredMmsiList: number[]) {
|
||||||
|
if (!Array.isArray(hoveredMmsiList) || hoveredMmsiList.length === 0) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
const literal = ['literal', hoveredMmsiList] as unknown[];
|
||||||
|
return [
|
||||||
|
'any',
|
||||||
|
['in', ['to-number', ['get', aField]], literal],
|
||||||
|
['in', ['to-number', ['get', bField]], literal],
|
||||||
|
] as unknown[];
|
||||||
|
}
|
||||||
|
|
||||||
|
export function makeFleetOwnerMatchExpr(hoveredOwnerKeys: string[]) {
|
||||||
|
if (!Array.isArray(hoveredOwnerKeys) || hoveredOwnerKeys.length === 0) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
const expr = ['match', ['to-string', ['coalesce', ['get', 'ownerKey'], '']]] as unknown[];
|
||||||
|
for (const ownerKey of hoveredOwnerKeys) {
|
||||||
|
expr.push(String(ownerKey), true);
|
||||||
|
}
|
||||||
|
expr.push(false);
|
||||||
|
return expr;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function makeFleetMemberMatchExpr(hoveredFleetMmsiList: number[]) {
|
||||||
|
if (!Array.isArray(hoveredFleetMmsiList) || hoveredFleetMmsiList.length === 0) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
const clauses = hoveredFleetMmsiList.map((mmsi) =>
|
||||||
|
['in', mmsi, ['coalesce', ['get', 'vesselMmsis'], ['literal', []]]] as unknown[],
|
||||||
|
);
|
||||||
|
return ['any', ...clauses] as unknown[];
|
||||||
|
}
|
||||||
|
|
||||||
|
export function makeGlobeCircleRadiusExpr() {
|
||||||
|
const base3 = 4;
|
||||||
|
const base7 = 6;
|
||||||
|
const base10 = 8;
|
||||||
|
const base14 = 12;
|
||||||
|
const base18 = 32;
|
||||||
|
|
||||||
|
return [
|
||||||
|
'interpolate',
|
||||||
|
['linear'],
|
||||||
|
['zoom'],
|
||||||
|
3,
|
||||||
|
['case', ['==', ['get', 'selected'], 1], 4.6, ['==', ['get', 'highlighted'], 1], 4.2, base3],
|
||||||
|
7,
|
||||||
|
['case', ['==', ['get', 'selected'], 1], 6.8, ['==', ['get', 'highlighted'], 1], 6.2, base7],
|
||||||
|
10,
|
||||||
|
['case', ['==', ['get', 'selected'], 1], 9.0, ['==', ['get', 'highlighted'], 1], 8.2, base10],
|
||||||
|
14,
|
||||||
|
['case', ['==', ['get', 'selected'], 1], 13.5, ['==', ['get', 'highlighted'], 1], 12.6, base14],
|
||||||
|
18,
|
||||||
|
['case', ['==', ['get', 'selected'], 1], 36, ['==', ['get', 'highlighted'], 1], 34, base18],
|
||||||
|
];
|
||||||
|
}
|
||||||
|
|
||||||
|
export const GLOBE_SHIP_CIRCLE_RADIUS_EXPR = makeGlobeCircleRadiusExpr() as never;
|
||||||
79
apps/web/src/widgets/map3d/lib/setUtils.ts
Normal file
79
apps/web/src/widgets/map3d/lib/setUtils.ts
Normal file
@ -0,0 +1,79 @@
|
|||||||
|
export function toNumberSet(values: number[] | undefined | null) {
|
||||||
|
const out = new Set<number>();
|
||||||
|
if (!values) return out;
|
||||||
|
for (const value of values) {
|
||||||
|
if (Number.isFinite(value)) {
|
||||||
|
out.add(value);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return out;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function mergeNumberSets(...sets: Set<number>[]) {
|
||||||
|
const out = new Set<number>();
|
||||||
|
for (const s of sets) {
|
||||||
|
for (const v of s) {
|
||||||
|
out.add(v);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return out;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function makeSetSignature(values: Set<number>) {
|
||||||
|
return Array.from(values).sort((a, b) => a - b).join(',');
|
||||||
|
}
|
||||||
|
|
||||||
|
export function toSafeNumber(value: unknown): number | null {
|
||||||
|
if (typeof value === 'number' && Number.isFinite(value)) return value;
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function toIntMmsi(value: unknown): number | null {
|
||||||
|
const n = toSafeNumber(value);
|
||||||
|
if (n == null) return null;
|
||||||
|
return Math.trunc(n);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function isFiniteNumber(x: unknown): x is number {
|
||||||
|
return typeof x === 'number' && Number.isFinite(x);
|
||||||
|
}
|
||||||
|
|
||||||
|
export const toNumberArray = (values: unknown): number[] => {
|
||||||
|
if (values == null) return [];
|
||||||
|
if (Array.isArray(values)) {
|
||||||
|
return values as unknown as number[];
|
||||||
|
}
|
||||||
|
if (typeof values === 'number' && Number.isFinite(values)) {
|
||||||
|
return [values];
|
||||||
|
}
|
||||||
|
if (typeof values === 'string') {
|
||||||
|
const value = toSafeNumber(Number(values));
|
||||||
|
return value == null ? [] : [value];
|
||||||
|
}
|
||||||
|
if (typeof values === 'object') {
|
||||||
|
if (typeof (values as { [Symbol.iterator]?: unknown })?.[Symbol.iterator] === 'function') {
|
||||||
|
try {
|
||||||
|
return Array.from(values as Iterable<unknown>) as number[];
|
||||||
|
} catch {
|
||||||
|
return [];
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return [];
|
||||||
|
};
|
||||||
|
|
||||||
|
export const makeUniqueSorted = (values: unknown) => {
|
||||||
|
const maybeArray = toNumberArray(values);
|
||||||
|
const normalized = Array.isArray(maybeArray) ? maybeArray : [];
|
||||||
|
const unique = Array.from(new Set(normalized.filter((value) => Number.isFinite(value))));
|
||||||
|
unique.sort((a, b) => a - b);
|
||||||
|
return unique;
|
||||||
|
};
|
||||||
|
|
||||||
|
export const equalNumberArrays = (a: number[], b: number[]) => {
|
||||||
|
if (a.length !== b.length) return false;
|
||||||
|
for (let i = 0; i < a.length; i += 1) {
|
||||||
|
if (a[i] !== b[i]) return false;
|
||||||
|
}
|
||||||
|
return true;
|
||||||
|
};
|
||||||
30
apps/web/src/widgets/map3d/lib/shipIconCache.ts
Normal file
30
apps/web/src/widgets/map3d/lib/shipIconCache.ts
Normal file
@ -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();
|
||||||
118
apps/web/src/widgets/map3d/lib/shipUtils.ts
Normal file
118
apps/web/src/widgets/map3d/lib/shipUtils.ts
Normal file
@ -0,0 +1,118 @@
|
|||||||
|
import type { AisTarget } from '../../../entities/aisTarget/model/types';
|
||||||
|
import type { LegacyVesselInfo } from '../../../entities/legacyVessel/model/types';
|
||||||
|
import { rgbToHex } from '../../../shared/lib/map/palette';
|
||||||
|
import {
|
||||||
|
ANCHOR_SPEED_THRESHOLD_KN,
|
||||||
|
LEGACY_CODE_COLORS,
|
||||||
|
MAP_SELECTED_SHIP_RGB,
|
||||||
|
MAP_HIGHLIGHT_SHIP_RGB,
|
||||||
|
MAP_DEFAULT_SHIP_RGB,
|
||||||
|
} from '../constants';
|
||||||
|
import { isFiniteNumber } from './setUtils';
|
||||||
|
import { normalizeAngleDeg } from './geometry';
|
||||||
|
|
||||||
|
export function toValidBearingDeg(value: unknown): number | null {
|
||||||
|
if (typeof value !== 'number' || !Number.isFinite(value)) return null;
|
||||||
|
if (value === 511) return null;
|
||||||
|
if (value < 0) return null;
|
||||||
|
if (value >= 360) return null;
|
||||||
|
return value;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function isAnchoredShip({
|
||||||
|
sog,
|
||||||
|
cog,
|
||||||
|
heading,
|
||||||
|
}: {
|
||||||
|
sog: number | null | undefined;
|
||||||
|
cog: number | null | undefined;
|
||||||
|
heading: number | null | undefined;
|
||||||
|
}): boolean {
|
||||||
|
if (!isFiniteNumber(sog)) return true;
|
||||||
|
if (sog <= ANCHOR_SPEED_THRESHOLD_KN) return true;
|
||||||
|
return toValidBearingDeg(cog) == null && toValidBearingDeg(heading) == null;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function getDisplayHeading({
|
||||||
|
cog,
|
||||||
|
heading,
|
||||||
|
offset = 0,
|
||||||
|
}: {
|
||||||
|
cog: number | null | undefined;
|
||||||
|
heading: number | null | undefined;
|
||||||
|
offset?: number;
|
||||||
|
}) {
|
||||||
|
const raw = toValidBearingDeg(cog) ?? toValidBearingDeg(heading) ?? 0;
|
||||||
|
return normalizeAngleDeg(raw, offset);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function lightenColor(rgb: [number, number, number], ratio = 0.32) {
|
||||||
|
const out = rgb.map((v) => Math.round(v + (255 - v) * ratio) as number) as [number, number, number];
|
||||||
|
return out;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function getGlobeBaseShipColor({
|
||||||
|
legacy,
|
||||||
|
sog,
|
||||||
|
}: {
|
||||||
|
legacy: string | null;
|
||||||
|
sog: number | null;
|
||||||
|
}) {
|
||||||
|
if (legacy) {
|
||||||
|
const rgb = LEGACY_CODE_COLORS[legacy];
|
||||||
|
if (rgb) return rgbToHex(lightenColor(rgb, 0.38));
|
||||||
|
}
|
||||||
|
|
||||||
|
// Keep alpha control in icon-opacity only to avoid double-multiplying transparency.
|
||||||
|
if (!isFiniteNumber(sog)) return '#64748b';
|
||||||
|
if (sog >= 10) return '#94a3b8';
|
||||||
|
if (sog >= 1) return '#64748b';
|
||||||
|
return '#475569';
|
||||||
|
}
|
||||||
|
|
||||||
|
export function getShipColor(
|
||||||
|
t: AisTarget,
|
||||||
|
selectedMmsi: number | null,
|
||||||
|
legacyShipCode: string | null,
|
||||||
|
highlightedMmsis: Set<number>,
|
||||||
|
): [number, number, number, number] {
|
||||||
|
if (selectedMmsi && t.mmsi === selectedMmsi) {
|
||||||
|
return [MAP_SELECTED_SHIP_RGB[0], MAP_SELECTED_SHIP_RGB[1], MAP_SELECTED_SHIP_RGB[2], 255];
|
||||||
|
}
|
||||||
|
if (highlightedMmsis.has(t.mmsi)) {
|
||||||
|
return [MAP_HIGHLIGHT_SHIP_RGB[0], MAP_HIGHLIGHT_SHIP_RGB[1], MAP_HIGHLIGHT_SHIP_RGB[2], 235];
|
||||||
|
}
|
||||||
|
if (legacyShipCode) {
|
||||||
|
const rgb = LEGACY_CODE_COLORS[legacyShipCode];
|
||||||
|
if (rgb) return [rgb[0], rgb[1], rgb[2], 235];
|
||||||
|
return [245, 158, 11, 235];
|
||||||
|
}
|
||||||
|
if (!isFiniteNumber(t.sog)) return [MAP_DEFAULT_SHIP_RGB[0], MAP_DEFAULT_SHIP_RGB[1], MAP_DEFAULT_SHIP_RGB[2], 130];
|
||||||
|
if (t.sog >= 10) return [148, 163, 184, 185];
|
||||||
|
if (t.sog >= 1) return [MAP_DEFAULT_SHIP_RGB[0], MAP_DEFAULT_SHIP_RGB[1], MAP_DEFAULT_SHIP_RGB[2], 175];
|
||||||
|
return [71, 85, 105, 165];
|
||||||
|
}
|
||||||
|
|
||||||
|
export function buildGlobeShipFeature(
|
||||||
|
t: AisTarget,
|
||||||
|
legacy: LegacyVesselInfo | undefined,
|
||||||
|
selectedMmsi: number | null,
|
||||||
|
highlightedMmsis: Set<number>,
|
||||||
|
offset: number,
|
||||||
|
) {
|
||||||
|
const isSelected = selectedMmsi != null && t.mmsi === selectedMmsi ? 1 : 0;
|
||||||
|
const isHighlighted = highlightedMmsis.has(t.mmsi) ? 1 : 0;
|
||||||
|
const anchored = isAnchoredShip(t);
|
||||||
|
|
||||||
|
return {
|
||||||
|
mmsi: t.mmsi,
|
||||||
|
heading: getDisplayHeading({ cog: t.cog, heading: t.heading, offset }),
|
||||||
|
anchored,
|
||||||
|
color: getGlobeBaseShipColor({ legacy: legacy?.shipCode ?? null, sog: t.sog ?? null }),
|
||||||
|
selected: isSelected,
|
||||||
|
highlighted: isHighlighted,
|
||||||
|
permitted: legacy ? 1 : 0,
|
||||||
|
labelName: (t.name || '').trim() || legacy?.shipNameCn || legacy?.shipNameRoman || '',
|
||||||
|
legacyTag: legacy ? `${legacy.permitNo} (${legacy.shipCode})` : '',
|
||||||
|
};
|
||||||
|
}
|
||||||
221
apps/web/src/widgets/map3d/lib/tooltips.ts
Normal file
221
apps/web/src/widgets/map3d/lib/tooltips.ts
Normal file
@ -0,0 +1,221 @@
|
|||||||
|
import type { AisTarget } from '../../../entities/aisTarget/model/types';
|
||||||
|
import type { LegacyVesselInfo } from '../../../entities/legacyVessel/model/types';
|
||||||
|
import { fmtIsoFull } from '../../../shared/lib/datetime';
|
||||||
|
import { isFiniteNumber, toSafeNumber } from './setUtils';
|
||||||
|
|
||||||
|
export function formatNm(value: number | null | undefined) {
|
||||||
|
if (!isFiniteNumber(value)) return '-';
|
||||||
|
return `${value.toFixed(2)} NM`;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function getLegacyTag(legacyHits: Map<number, LegacyVesselInfo> | null | undefined, mmsi: number) {
|
||||||
|
const legacy = legacyHits?.get(mmsi);
|
||||||
|
if (!legacy) return null;
|
||||||
|
return `${legacy.permitNo} (${legacy.shipCode})`;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function getTargetName(
|
||||||
|
mmsi: number,
|
||||||
|
targetByMmsi: Map<number, AisTarget>,
|
||||||
|
legacyHits: Map<number, LegacyVesselInfo> | null | undefined,
|
||||||
|
) {
|
||||||
|
const legacy = legacyHits?.get(mmsi);
|
||||||
|
const target = targetByMmsi.get(mmsi);
|
||||||
|
return (
|
||||||
|
(target?.name || '').trim() || legacy?.shipNameCn || legacy?.shipNameRoman || `MMSI ${mmsi}`
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function getShipTooltipHtml({
|
||||||
|
mmsi,
|
||||||
|
targetByMmsi,
|
||||||
|
legacyHits,
|
||||||
|
}: {
|
||||||
|
mmsi: number;
|
||||||
|
targetByMmsi: Map<number, AisTarget>;
|
||||||
|
legacyHits: Map<number, LegacyVesselInfo> | null | undefined;
|
||||||
|
}) {
|
||||||
|
const legacy = legacyHits?.get(mmsi);
|
||||||
|
const t = targetByMmsi.get(mmsi);
|
||||||
|
const name = getTargetName(mmsi, targetByMmsi, legacyHits);
|
||||||
|
const sog = isFiniteNumber(t?.sog) ? t.sog : null;
|
||||||
|
const cog = isFiniteNumber(t?.cog) ? t.cog : null;
|
||||||
|
const msg = t?.messageTimestamp ?? null;
|
||||||
|
const vesselType = t?.vesselType || '';
|
||||||
|
|
||||||
|
const legacyHtml = legacy
|
||||||
|
? `<div style="margin-top: 6px; padding-top: 6px; border-top: 1px solid rgba(255,255,255,.08)">
|
||||||
|
<div><b>CN Permit</b> · <b>${legacy.shipCode}</b> · ${legacy.permitNo}</div>
|
||||||
|
<div>유효범위: ${legacy.workSeaArea || '-'}</div>
|
||||||
|
</div>`
|
||||||
|
: '';
|
||||||
|
|
||||||
|
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>MMSI: <b>${mmsi}</b>${vesselType ? ` · ${vesselType}` : ''}</div>
|
||||||
|
<div>SOG: <b>${sog ?? '?'}</b> kt · COG: <b>${cog ?? '?'}</b>°</div>
|
||||||
|
${msg ? `<div style="opacity:.7; font-size: 11px; margin-top: 4px;">${fmtIsoFull(msg)}</div>` : ''}
|
||||||
|
${legacyHtml}
|
||||||
|
</div>`,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
export function getPairLinkTooltipHtml({
|
||||||
|
warn,
|
||||||
|
distanceNm,
|
||||||
|
aMmsi,
|
||||||
|
bMmsi,
|
||||||
|
legacyHits,
|
||||||
|
targetByMmsi,
|
||||||
|
}: {
|
||||||
|
warn: boolean;
|
||||||
|
distanceNm: number | null | undefined;
|
||||||
|
aMmsi: number;
|
||||||
|
bMmsi: number;
|
||||||
|
legacyHits: Map<number, LegacyVesselInfo> | null | undefined;
|
||||||
|
targetByMmsi: Map<number, AisTarget>;
|
||||||
|
}) {
|
||||||
|
const d = formatNm(distanceNm);
|
||||||
|
const a = getTargetName(aMmsi, targetByMmsi, legacyHits);
|
||||||
|
const b = getTargetName(bMmsi, targetByMmsi, legacyHits);
|
||||||
|
const aTag = getLegacyTag(legacyHits, aMmsi);
|
||||||
|
const bTag = getLegacyTag(legacyHits, bMmsi);
|
||||||
|
return {
|
||||||
|
html: `<div style="font-family: system-ui; font-size: 12px;">
|
||||||
|
<div style="font-weight: 700; margin-bottom: 4px;">쌍 연결</div>
|
||||||
|
<div>${aTag ?? `MMSI ${aMmsi}`}</div>
|
||||||
|
<div style="opacity:.85;">↔ ${bTag ?? `MMSI ${bMmsi}`}</div>
|
||||||
|
<div style="margin-top: 4px;">거리: <b>${d}</b> · 상태: <b>${warn ? '주의' : '정상'}</b></div>
|
||||||
|
<div style="opacity:.6; margin-top: 3px;">${a} / ${b}</div>
|
||||||
|
</div>`,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
export function getFcLinkTooltipHtml({
|
||||||
|
suspicious,
|
||||||
|
distanceNm,
|
||||||
|
fcMmsi,
|
||||||
|
otherMmsi,
|
||||||
|
legacyHits,
|
||||||
|
targetByMmsi,
|
||||||
|
}: {
|
||||||
|
suspicious: boolean;
|
||||||
|
distanceNm: number | null | undefined;
|
||||||
|
fcMmsi: number;
|
||||||
|
otherMmsi: number;
|
||||||
|
legacyHits: Map<number, LegacyVesselInfo> | null | undefined;
|
||||||
|
targetByMmsi: Map<number, AisTarget>;
|
||||||
|
}) {
|
||||||
|
const d = formatNm(distanceNm);
|
||||||
|
const a = getTargetName(fcMmsi, targetByMmsi, legacyHits);
|
||||||
|
const b = getTargetName(otherMmsi, targetByMmsi, legacyHits);
|
||||||
|
const aTag = getLegacyTag(legacyHits, fcMmsi);
|
||||||
|
const bTag = getLegacyTag(legacyHits, otherMmsi);
|
||||||
|
return {
|
||||||
|
html: `<div style="font-family: system-ui; font-size: 12px;">
|
||||||
|
<div style="font-weight: 700; margin-bottom: 4px;">환적 연결</div>
|
||||||
|
<div>${aTag ?? `MMSI ${fcMmsi}`}</div>
|
||||||
|
<div style="opacity:.85;">→ ${bTag ?? `MMSI ${otherMmsi}`}</div>
|
||||||
|
<div style="margin-top: 4px;">거리: <b>${d}</b> · 상태: <b>${suspicious ? '의심' : '일반'}</b></div>
|
||||||
|
<div style="opacity:.6; margin-top: 3px;">${a} / ${b}</div>
|
||||||
|
</div>`,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
export function getRangeTooltipHtml({
|
||||||
|
warn,
|
||||||
|
distanceNm,
|
||||||
|
aMmsi,
|
||||||
|
bMmsi,
|
||||||
|
legacyHits,
|
||||||
|
}: {
|
||||||
|
warn: boolean;
|
||||||
|
distanceNm: number | null | undefined;
|
||||||
|
aMmsi: number;
|
||||||
|
bMmsi: number;
|
||||||
|
legacyHits: Map<number, LegacyVesselInfo> | null | undefined;
|
||||||
|
}) {
|
||||||
|
const d = formatNm(distanceNm);
|
||||||
|
const aTag = getLegacyTag(legacyHits, aMmsi);
|
||||||
|
const bTag = getLegacyTag(legacyHits, bMmsi);
|
||||||
|
const radiusNm = toSafeNumber(distanceNm);
|
||||||
|
return {
|
||||||
|
html: `<div style="font-family: system-ui; font-size: 12px;">
|
||||||
|
<div style="font-weight: 700; margin-bottom: 4px;">쌍 연결범위</div>
|
||||||
|
<div>${aTag ?? `MMSI ${aMmsi}`}</div>
|
||||||
|
<div style="opacity:.85;">↔ ${bTag ?? `MMSI ${bMmsi}`}</div>
|
||||||
|
<div style="margin-top: 4px;">범위: <b>${d}</b> · 반경: <b>${formatNm(radiusNm == null ? null : radiusNm / 2)}</b> · 상태: <b>${warn ? '주의' : '정상'}</b></div>
|
||||||
|
</div>`,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
export function getFleetCircleTooltipHtml({
|
||||||
|
ownerKey,
|
||||||
|
ownerLabel,
|
||||||
|
count,
|
||||||
|
}: {
|
||||||
|
ownerKey: string;
|
||||||
|
ownerLabel?: string;
|
||||||
|
count: number;
|
||||||
|
}) {
|
||||||
|
const displayOwner = ownerLabel && ownerLabel.trim() ? ownerLabel : ownerKey;
|
||||||
|
return {
|
||||||
|
html: `<div style="font-family: system-ui; font-size: 12px;">
|
||||||
|
<div style="font-weight: 700; margin-bottom: 4px;">선단 범위</div>
|
||||||
|
<div>소유주: ${displayOwner || '-'}</div>
|
||||||
|
<div>선박 수: <b>${count}</b></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>`,
|
||||||
|
};
|
||||||
|
}
|
||||||
40
apps/web/src/widgets/map3d/lib/zoneUtils.ts
Normal file
40
apps/web/src/widgets/map3d/lib/zoneUtils.ts
Normal file
@ -0,0 +1,40 @@
|
|||||||
|
import type { ZoneId } from '../../../entities/zone/model/meta';
|
||||||
|
import { ZONE_META } from '../../../entities/zone/model/meta';
|
||||||
|
|
||||||
|
function toTextValue(value: unknown): string {
|
||||||
|
if (value == null) return '';
|
||||||
|
return String(value).trim();
|
||||||
|
}
|
||||||
|
|
||||||
|
export function getZoneIdFromProps(props: Record<string, unknown> | null | undefined): string {
|
||||||
|
const safeProps = props || {};
|
||||||
|
const candidates = [
|
||||||
|
'zoneId',
|
||||||
|
'zone_id',
|
||||||
|
'zoneIdNo',
|
||||||
|
'zoneKey',
|
||||||
|
'zoneCode',
|
||||||
|
'ZONE_ID',
|
||||||
|
'ZONECODE',
|
||||||
|
'id',
|
||||||
|
];
|
||||||
|
|
||||||
|
for (const key of candidates) {
|
||||||
|
const value = toTextValue(safeProps[key]);
|
||||||
|
if (value) return value;
|
||||||
|
}
|
||||||
|
|
||||||
|
return '';
|
||||||
|
}
|
||||||
|
|
||||||
|
export function getZoneDisplayNameFromProps(props: Record<string, unknown> | null | undefined): string {
|
||||||
|
const safeProps = props || {};
|
||||||
|
const nameCandidates = ['zoneName', 'zoneLabel', 'NAME', 'name', 'ZONE_NM', 'label'];
|
||||||
|
for (const key of nameCandidates) {
|
||||||
|
const name = toTextValue(safeProps[key]);
|
||||||
|
if (name) return name;
|
||||||
|
}
|
||||||
|
const zoneId = getZoneIdFromProps(safeProps);
|
||||||
|
if (!zoneId) return '수역';
|
||||||
|
return ZONE_META[(zoneId as ZoneId)]?.name || `수역 ${zoneId}`;
|
||||||
|
}
|
||||||
96
apps/web/src/widgets/map3d/types.ts
Normal file
96
apps/web/src/widgets/map3d/types.ts
Normal file
@ -0,0 +1,96 @@
|
|||||||
|
import type { AisTarget } from '../../entities/aisTarget/model/types';
|
||||||
|
import type { LegacyVesselInfo } from '../../entities/legacyVessel/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 { MapToggleState } from '../../features/mapToggles/MapToggles';
|
||||||
|
import type { FcLink, FleetCircle, PairLink } from '../../features/legacyDashboard/model/types';
|
||||||
|
import type { MapStyleSettings } from '../../features/mapSettings/types';
|
||||||
|
|
||||||
|
export type Map3DSettings = {
|
||||||
|
showSeamark: boolean;
|
||||||
|
showShips: boolean;
|
||||||
|
showDensity: boolean;
|
||||||
|
};
|
||||||
|
|
||||||
|
export type BaseMapId = 'enhanced' | 'legacy';
|
||||||
|
export type MapProjectionId = 'mercator' | 'globe';
|
||||||
|
|
||||||
|
export interface MapViewState {
|
||||||
|
center: [number, number]; // [lon, lat]
|
||||||
|
zoom: number;
|
||||||
|
bearing: number;
|
||||||
|
pitch: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface Map3DProps {
|
||||||
|
targets: AisTarget[];
|
||||||
|
zones: ZonesGeoJson | null;
|
||||||
|
selectedMmsi: number | null;
|
||||||
|
hoveredMmsiSet?: number[];
|
||||||
|
hoveredFleetMmsiSet?: number[];
|
||||||
|
hoveredPairMmsiSet?: number[];
|
||||||
|
hoveredFleetOwnerKey?: string | null;
|
||||||
|
highlightedMmsiSet?: number[];
|
||||||
|
settings: Map3DSettings;
|
||||||
|
baseMap: BaseMapId;
|
||||||
|
projection: MapProjectionId;
|
||||||
|
overlays: MapToggleState;
|
||||||
|
onSelectMmsi: (mmsi: number | null) => void;
|
||||||
|
onToggleHighlightMmsi?: (mmsi: number) => void;
|
||||||
|
onViewBboxChange?: (bbox: [number, number, number, number]) => void;
|
||||||
|
legacyHits?: Map<number, LegacyVesselInfo> | null;
|
||||||
|
pairLinks?: PairLink[];
|
||||||
|
fcLinks?: FcLink[];
|
||||||
|
fleetCircles?: FleetCircle[];
|
||||||
|
onProjectionLoadingChange?: (loading: boolean) => void;
|
||||||
|
fleetFocus?: {
|
||||||
|
id: string | number;
|
||||||
|
center: [number, number];
|
||||||
|
zoom?: number;
|
||||||
|
};
|
||||||
|
onHoverFleet?: (ownerKey: string | null, fleetMmsiSet: number[]) => void;
|
||||||
|
onClearFleetHover?: () => void;
|
||||||
|
onHoverMmsi?: (mmsiList: number[]) => void;
|
||||||
|
onClearMmsiHover?: () => void;
|
||||||
|
onHoverPair?: (mmsiList: number[]) => void;
|
||||||
|
onClearPairHover?: () => void;
|
||||||
|
subcableGeo?: SubcableGeoJson | null;
|
||||||
|
hoveredCableId?: string | null;
|
||||||
|
onHoverCable?: (cableId: string | null) => void;
|
||||||
|
onClickCable?: (cableId: string | null) => void;
|
||||||
|
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 = {
|
||||||
|
from: [number, number];
|
||||||
|
to: [number, number];
|
||||||
|
suspicious: boolean;
|
||||||
|
distanceNm?: number;
|
||||||
|
fromMmsi?: number;
|
||||||
|
toMmsi?: number;
|
||||||
|
};
|
||||||
|
|
||||||
|
export type PairRangeCircle = {
|
||||||
|
center: [number, number]; // [lon, lat]
|
||||||
|
radiusNm: number;
|
||||||
|
warn: boolean;
|
||||||
|
aMmsi: number;
|
||||||
|
bMmsi: number;
|
||||||
|
distanceNm: number;
|
||||||
|
};
|
||||||
|
|
||||||
|
export type BathyZoomRange = {
|
||||||
|
id: string;
|
||||||
|
mercator: [number, number];
|
||||||
|
globe: [number, number];
|
||||||
|
};
|
||||||
171
apps/web/src/widgets/subcableInfo/SubcableInfoPanel.tsx
Normal file
171
apps/web/src/widgets/subcableInfo/SubcableInfoPanel.tsx
Normal file
@ -0,0 +1,171 @@
|
|||||||
|
import type { SubcableDetail } from '../../entities/subcable/model/types';
|
||||||
|
|
||||||
|
interface Props {
|
||||||
|
detail: SubcableDetail;
|
||||||
|
color?: string;
|
||||||
|
onClose: () => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function SubcableInfoPanel({ detail, color, onClose }: Props) {
|
||||||
|
const landingCount = detail.landing_points.length;
|
||||||
|
const countries = [...new Set(detail.landing_points.map((lp) => lp.country).filter(Boolean))];
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="map-info" style={{ width: 320 }}>
|
||||||
|
<button className="close-btn" onClick={onClose} aria-label="close">
|
||||||
|
✕
|
||||||
|
</button>
|
||||||
|
|
||||||
|
{/* ── Header ── */}
|
||||||
|
<div style={{ marginBottom: 10, paddingRight: 20 }}>
|
||||||
|
<div style={{ display: 'flex', alignItems: 'center', gap: 8 }}>
|
||||||
|
{color && (
|
||||||
|
<div
|
||||||
|
style={{
|
||||||
|
width: 14,
|
||||||
|
height: 14,
|
||||||
|
borderRadius: 3,
|
||||||
|
backgroundColor: color,
|
||||||
|
flexShrink: 0,
|
||||||
|
border: '1px solid rgba(255,255,255,0.15)',
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
<div style={{ fontSize: 15, fontWeight: 800, color: 'var(--accent)', lineHeight: 1.3 }}>
|
||||||
|
{detail.name}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
{detail.is_planned && (
|
||||||
|
<span
|
||||||
|
style={{
|
||||||
|
display: 'inline-block',
|
||||||
|
marginTop: 4,
|
||||||
|
fontSize: 9,
|
||||||
|
fontWeight: 700,
|
||||||
|
color: '#F59E0B',
|
||||||
|
background: 'rgba(245,158,11,0.12)',
|
||||||
|
padding: '1px 6px',
|
||||||
|
borderRadius: 3,
|
||||||
|
letterSpacing: 0.5,
|
||||||
|
textTransform: 'uppercase',
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
Planned
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
</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 && (
|
||||||
|
<div style={{ marginTop: 10 }}>
|
||||||
|
<div
|
||||||
|
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
|
||||||
|
style={{
|
||||||
|
maxHeight: 160,
|
||||||
|
overflowY: 'auto',
|
||||||
|
display: 'flex',
|
||||||
|
flexDirection: 'column',
|
||||||
|
gap: 2,
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{detail.landing_points.map((lp) => (
|
||||||
|
<div
|
||||||
|
key={lp.id}
|
||||||
|
style={{
|
||||||
|
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>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* ── Notes ── */}
|
||||||
|
{detail.notes && (
|
||||||
|
<div
|
||||||
|
style={{
|
||||||
|
marginTop: 10,
|
||||||
|
fontSize: 10,
|
||||||
|
color: 'var(--muted)',
|
||||||
|
fontStyle: 'italic',
|
||||||
|
lineHeight: 1.5,
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{detail.notes}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* ── Link ── */}
|
||||||
|
{detail.url && (
|
||||||
|
<div style={{ marginTop: 10 }}>
|
||||||
|
<a
|
||||||
|
href={detail.url}
|
||||||
|
target="_blank"
|
||||||
|
rel="noopener noreferrer"
|
||||||
|
style={{ fontSize: 10, color: 'var(--accent)', textDecoration: 'none' }}
|
||||||
|
>
|
||||||
|
상세정보 ↗
|
||||||
|
</a>
|
||||||
|
</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>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
307
apps/web/src/widgets/trackReplay/GlobalTrackReplayPanel.tsx
Normal file
307
apps/web/src/widgets/trackReplay/GlobalTrackReplayPanel.tsx
Normal file
@ -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>
|
||||||
|
);
|
||||||
|
}
|
||||||
316
apps/web/src/widgets/weatherOverlay/WeatherOverlayPanel.tsx
Normal file
316
apps/web/src/widgets/weatherOverlay/WeatherOverlayPanel.tsx
Normal file
@ -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>
|
||||||
|
)}
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
}
|
||||||
118
apps/web/src/widgets/weatherPanel/WeatherPanel.tsx
Normal file
118
apps/web/src/widgets/weatherPanel/WeatherPanel.tsx
Normal file
@ -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
1274
package-lock.json
generated
파일 크기가 너무 크기때문에 변경 상태를 표시하지 않습니다.
Load Diff
@ -10,7 +10,8 @@
|
|||||||
"build:web": "npm -w @wing/web run build",
|
"build:web": "npm -w @wing/web run build",
|
||||||
"build:api": "npm -w @wing/api run build",
|
"build:api": "npm -w @wing/api run build",
|
||||||
"lint": "npm -w @wing/web run lint",
|
"lint": "npm -w @wing/web run lint",
|
||||||
"prepare:data": "node scripts/prepare-zones.mjs && node scripts/prepare-legacy.mjs"
|
"prepare:data": "node scripts/prepare-zones.mjs && node scripts/prepare-legacy.mjs",
|
||||||
|
"prepare:subcables": "node scripts/prepare-subcables.mjs"
|
||||||
},
|
},
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
"xlsx": "^0.18.5"
|
"xlsx": "^0.18.5"
|
||||||
|
|||||||
Some files were not shown because too many files have changed in this diff Show More
불러오는 중...
Reference in New Issue
Block a user