Compare commits
49 커밋
feature/au
...
develop
| 작성자 | SHA1 | 날짜 | |
|---|---|---|---|
| 9048dfdfc6 | |||
| d9b8e9ca44 | |||
| 541135977c | |||
| 61fc3bbce4 | |||
| 255c082436 | |||
| 40229a75c0 | |||
| a132c7eaf8 | |||
| 2adcbc9a93 | |||
| e9a41c6663 | |||
| 6167a0ebd8 | |||
| a4859f54bc | |||
| c8c1b556d6 | |||
| ec9d894ac8 | |||
| 3fa0b67e97 | |||
| ec03a88fbd | |||
| 4d67b26ffa | |||
| b1551f800b | |||
| e2dc927ad2 | |||
| c2ca830ef0 | |||
| 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 |
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)
|
||||
- **패키지 매니저**: npm (workspaces)
|
||||
- **구조**: apps/web (프론트엔드) + apps/api (백엔드 API)
|
||||
- **구조**: apps/web (프론트엔드) + apps/api (백엔드 API 프록시)
|
||||
|
||||
## 빌드 및 실행
|
||||
|
||||
```bash
|
||||
# 의존성 설치
|
||||
npm install
|
||||
|
||||
# 전체 개발 서버
|
||||
npm run dev
|
||||
|
||||
# 개별 개발 서버
|
||||
npm run dev:web # 프론트엔드 (Vite)
|
||||
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
|
||||
npm install # 의존성 설치
|
||||
npm run dev # 전체 개발 서버
|
||||
npm run dev:web # 프론트엔드 (Vite)
|
||||
npm run dev:api # 백엔드 (Fastify + tsx watch)
|
||||
npm run build # 전체 빌드 (web + api)
|
||||
npm run build:web # 프론트엔드만
|
||||
npm run lint # apps/web ESLint
|
||||
npm run prepare:data # 정적 데이터 준비
|
||||
```
|
||||
|
||||
## 프로젝트 구조
|
||||
@ -37,19 +25,18 @@ npm run prepare:data
|
||||
```
|
||||
gc-wing-dev/
|
||||
├── apps/
|
||||
│ ├── web/ # React 19 + Vite 7 + MapLibre + Deck.gl
|
||||
│ ├── web/ # @wing/web - React 19 + Vite 7
|
||||
│ │ └── src/
|
||||
│ │ ├── app/ # App.tsx, styles
|
||||
│ │ ├── entities/ # 도메인 모델 (vessel, zone, aisTarget, legacyVessel)
|
||||
│ │ ├── features/ # 기능 단위 (mapToggles, typeFilter, aisPolling 등)
|
||||
│ │ ├── pages/ # 페이지 (DashboardPage)
|
||||
│ │ ├── shared/ # 공통 유틸 (lib/geo, lib/color, lib/map)
|
||||
│ │ └── widgets/ # UI 위젯 (map3d, vesselList, info, alarms 등)
|
||||
│ └── api/ # Fastify 5 + TypeScript
|
||||
│ └── src/
|
||||
│ └── index.ts
|
||||
│ │ ├── app/ # App.tsx, styles.css
|
||||
│ │ ├── entities/ # 도메인 모델 (aisTarget, vessel, zone, legacyVessel, subcable)
|
||||
│ │ ├── features/ # 기능 모듈 (aisPolling, legacyDashboard, map3dSettings, mapSettings, mapToggles, typeFilter)
|
||||
│ │ ├── pages/ # dashboard, login, denied, pending
|
||||
│ │ ├── shared/ # auth (Google OAuth), lib (geo, color, map), hooks (usePersistedState)
|
||||
│ │ └── widgets/ # map3d, vesselList, info, alarms, relations, aisInfo, aisTargetList, topbar, speed, legend, subcableInfo
|
||||
│ └── api/ # @wing/api - Fastify 5
|
||||
│ └── src/index.ts # AIS 프록시 + zones 엔드포인트
|
||||
├── data/ # 정적 데이터
|
||||
├── scripts/ # 빌드 스크립트 (prepare-zones, prepare-legacy)
|
||||
├── scripts/ # prepare-zones.mjs, prepare-legacy.mjs
|
||||
└── legacy/ # 레거시 데이터
|
||||
```
|
||||
|
||||
@ -59,6 +46,7 @@ gc-wing-dev/
|
||||
|------|------|
|
||||
| 프론트엔드 | React 19, Vite 7, TypeScript 5.9 |
|
||||
| 지도 | MapLibre GL JS 5, Deck.gl 9 |
|
||||
| 인증 | Google OAuth (AuthProvider + ProtectedRoute) |
|
||||
| 백엔드 | Fastify 5, TypeScript |
|
||||
| 린트 | ESLint 9, Prettier |
|
||||
|
||||
|
||||
@ -131,6 +131,52 @@ function parseBbox(raw: string | undefined) {
|
||||
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) => {
|
||||
const zonesPath = path.resolve(
|
||||
process.cwd(),
|
||||
|
||||
@ -4,6 +4,9 @@
|
||||
<meta charset="UTF-8" />
|
||||
<link rel="icon" type="image/svg+xml" href="/favicon.svg" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||
<link rel="preconnect" href="https://fonts.googleapis.com" />
|
||||
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin />
|
||||
<link href="https://fonts.googleapis.com/css2?family=Noto+Sans+KR:wght@300;400;500;600;700;800;900&display=swap" rel="stylesheet" />
|
||||
<title>WING 조업감시 데모</title>
|
||||
</head>
|
||||
<body>
|
||||
|
||||
@ -12,16 +12,23 @@
|
||||
"dependencies": {
|
||||
"@deck.gl/aggregation-layers": "^9.2.7",
|
||||
"@deck.gl/core": "^9.2.7",
|
||||
"@deck.gl/extensions": "^9.2.7",
|
||||
"@deck.gl/geo-layers": "^9.2.7",
|
||||
"@deck.gl/layers": "^9.2.7",
|
||||
"@deck.gl/mapbox": "^9.2.7",
|
||||
"@maptiler/weather": "^3.1.1",
|
||||
"@react-oauth/google": "^0.13.4",
|
||||
"@stomp/stompjs": "^7.2.1",
|
||||
"@wing/ui": "*",
|
||||
"maplibre-gl": "^5.18.0",
|
||||
"react": "^19.2.0",
|
||||
"react-dom": "^19.2.0",
|
||||
"react-router": "^7.13.0"
|
||||
"react-router": "^7.13.0",
|
||||
"zustand": "^5.0.8"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@eslint/js": "^9.39.1",
|
||||
"@tailwindcss/vite": "^4.1.18",
|
||||
"@types/node": "^24.10.1",
|
||||
"@types/react": "^19.2.7",
|
||||
"@types/react-dom": "^19.2.3",
|
||||
@ -30,6 +37,7 @@
|
||||
"eslint-plugin-react-hooks": "^7.0.1",
|
||||
"eslint-plugin-react-refresh": "^0.4.24",
|
||||
"globals": "^16.5.0",
|
||||
"tailwindcss": "^4.1.18",
|
||||
"typescript": "~5.9.3",
|
||||
"typescript-eslint": "^8.48.0",
|
||||
"vite": "^7.3.1"
|
||||
|
||||
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]);
|
||||
}
|
||||
}
|
||||
파일 크기가 너무 크기때문에 변경 상태를 표시하지 않습니다.
Load Diff
19
apps/web/src/app/styles/base.css
Normal file
19
apps/web/src/app/styles/base.css
Normal file
@ -0,0 +1,19 @@
|
||||
/* Google Fonts: loaded via index.html <link> */
|
||||
/* CSS Reset: handled by Tailwind preflight */
|
||||
/* CSS Variables: defined in @wing/ui/theme/tokens.css */
|
||||
|
||||
html,
|
||||
body {
|
||||
height: 100%;
|
||||
}
|
||||
|
||||
body {
|
||||
font-family: "Noto Sans KR", sans-serif;
|
||||
background: var(--bg);
|
||||
color: var(--text);
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
#root {
|
||||
height: 100%;
|
||||
}
|
||||
159
apps/web/src/app/styles/components/ais-list.css
Normal file
159
apps/web/src/app/styles/components/ais-list.css
Normal file
@ -0,0 +1,159 @@
|
||||
/* AIS target list */
|
||||
.ais-q {
|
||||
flex: 1;
|
||||
font-size: 10px;
|
||||
padding: 6px 8px;
|
||||
border-radius: 6px;
|
||||
border: 1px solid var(--border);
|
||||
background: var(--wing-card-alpha);
|
||||
color: var(--text);
|
||||
outline: none;
|
||||
}
|
||||
|
||||
.ais-q::placeholder {
|
||||
color: var(--wing-muted);
|
||||
}
|
||||
|
||||
.ais-mode {
|
||||
display: flex;
|
||||
border: 1px solid var(--border);
|
||||
border-radius: 6px;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.ais-mode-btn {
|
||||
font-size: 9px;
|
||||
padding: 0 8px;
|
||||
height: 28px;
|
||||
border: none;
|
||||
background: var(--card);
|
||||
color: var(--muted);
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.ais-mode-btn.on {
|
||||
background: rgba(59, 130, 246, 0.18);
|
||||
color: var(--text);
|
||||
}
|
||||
|
||||
.ais-list {
|
||||
flex: 1;
|
||||
min-height: 0;
|
||||
overflow-y: auto;
|
||||
}
|
||||
|
||||
.ais-row {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 6px;
|
||||
padding: 6px 6px;
|
||||
border-radius: 6px;
|
||||
cursor: pointer;
|
||||
user-select: none;
|
||||
transition: background 0.12s, border-color 0.12s;
|
||||
border: 1px solid transparent;
|
||||
}
|
||||
|
||||
.ais-row:hover {
|
||||
background: var(--wing-card-alpha);
|
||||
border-color: var(--wing-border);
|
||||
}
|
||||
|
||||
.ais-row.sel {
|
||||
background: rgba(59, 130, 246, 0.14);
|
||||
border-color: rgba(59, 130, 246, 0.55);
|
||||
}
|
||||
|
||||
.ais-dot {
|
||||
width: 7px;
|
||||
height: 7px;
|
||||
border-radius: 50%;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.ais-nm {
|
||||
flex: 1;
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.ais-nm1 {
|
||||
font-size: 10px;
|
||||
font-weight: 700;
|
||||
white-space: nowrap;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
}
|
||||
|
||||
.ais-nm2 {
|
||||
font-size: 9px;
|
||||
color: var(--muted);
|
||||
white-space: nowrap;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
}
|
||||
|
||||
.ais-right {
|
||||
text-align: right;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.ais-badges {
|
||||
display: flex;
|
||||
gap: 4px;
|
||||
justify-content: flex-end;
|
||||
margin-bottom: 2px;
|
||||
flex-wrap: wrap;
|
||||
}
|
||||
|
||||
.ais-badge {
|
||||
font-size: 8px;
|
||||
padding: 1px 5px;
|
||||
border-radius: 5px;
|
||||
border: 1px solid rgba(255, 255, 255, 0.08);
|
||||
font-weight: 800;
|
||||
letter-spacing: 0.2px;
|
||||
color: #fff;
|
||||
background: rgba(100, 116, 139, 0.22);
|
||||
}
|
||||
|
||||
.ais-badge.pn {
|
||||
color: var(--muted);
|
||||
background: var(--wing-card-alpha);
|
||||
border-color: var(--wing-border);
|
||||
font-weight: 700;
|
||||
}
|
||||
|
||||
.ais-badge.PT {
|
||||
background: rgba(30, 64, 175, 0.28);
|
||||
border-color: rgba(30, 64, 175, 0.7);
|
||||
}
|
||||
.ais-badge.PT-S {
|
||||
background: rgba(234, 88, 12, 0.22);
|
||||
border-color: rgba(234, 88, 12, 0.7);
|
||||
}
|
||||
.ais-badge.GN {
|
||||
background: rgba(16, 185, 129, 0.22);
|
||||
border-color: rgba(16, 185, 129, 0.7);
|
||||
}
|
||||
.ais-badge.OT {
|
||||
background: rgba(139, 92, 246, 0.22);
|
||||
border-color: rgba(139, 92, 246, 0.7);
|
||||
}
|
||||
.ais-badge.PS {
|
||||
background: rgba(239, 68, 68, 0.22);
|
||||
border-color: rgba(239, 68, 68, 0.7);
|
||||
}
|
||||
.ais-badge.FC {
|
||||
background: rgba(245, 158, 11, 0.22);
|
||||
border-color: rgba(245, 158, 11, 0.7);
|
||||
}
|
||||
|
||||
.ais-sp {
|
||||
font-size: 10px;
|
||||
font-weight: 800;
|
||||
}
|
||||
|
||||
.ais-ts {
|
||||
font-size: 9px;
|
||||
color: var(--wing-muted);
|
||||
}
|
||||
95
apps/web/src/app/styles/components/alarms.css
Normal file
95
apps/web/src/app/styles/components/alarms.css
Normal file
@ -0,0 +1,95 @@
|
||||
/* Alarm */
|
||||
.ai {
|
||||
display: flex;
|
||||
gap: 6px;
|
||||
padding: 4px 6px;
|
||||
border-radius: 3px;
|
||||
margin-bottom: 2px;
|
||||
font-size: 9px;
|
||||
border-left: 3px solid;
|
||||
}
|
||||
|
||||
.ai.cr {
|
||||
border-color: var(--crit);
|
||||
background: rgba(239, 68, 68, 0.07);
|
||||
}
|
||||
|
||||
.ai.hi {
|
||||
border-color: var(--high);
|
||||
background: rgba(245, 158, 11, 0.05);
|
||||
}
|
||||
|
||||
.ai .at {
|
||||
color: var(--muted);
|
||||
font-size: 8px;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
/* Alarm filter (dropdown) */
|
||||
.alarm-filter {
|
||||
position: relative;
|
||||
}
|
||||
|
||||
.alarm-filter__summary {
|
||||
list-style: none;
|
||||
cursor: pointer;
|
||||
padding: 2px 8px;
|
||||
border-radius: 6px;
|
||||
border: 1px solid var(--border);
|
||||
background: var(--wing-card-alpha);
|
||||
color: var(--text);
|
||||
font-size: 8px;
|
||||
font-weight: 700;
|
||||
letter-spacing: 0.4px;
|
||||
user-select: none;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.alarm-filter__summary::-webkit-details-marker {
|
||||
display: none;
|
||||
}
|
||||
|
||||
.alarm-filter__menu {
|
||||
position: absolute;
|
||||
right: 0;
|
||||
top: 22px;
|
||||
z-index: 2000;
|
||||
min-width: 170px;
|
||||
padding: 6px;
|
||||
border-radius: 10px;
|
||||
border: 1px solid var(--border);
|
||||
background: var(--wing-glass-dense);
|
||||
box-shadow: 0 16px 50px rgba(0, 0, 0, 0.55);
|
||||
}
|
||||
|
||||
.alarm-filter__row {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 6px;
|
||||
padding: 4px 6px;
|
||||
border-radius: 6px;
|
||||
cursor: pointer;
|
||||
font-size: 10px;
|
||||
color: var(--text);
|
||||
user-select: none;
|
||||
}
|
||||
|
||||
.alarm-filter__row:hover {
|
||||
background: rgba(59, 130, 246, 0.08);
|
||||
}
|
||||
|
||||
.alarm-filter__row input {
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.alarm-filter__cnt {
|
||||
margin-left: auto;
|
||||
font-size: 9px;
|
||||
color: var(--muted);
|
||||
}
|
||||
|
||||
.alarm-filter__sep {
|
||||
height: 1px;
|
||||
background: var(--wing-border);
|
||||
margin: 4px 0;
|
||||
}
|
||||
126
apps/web/src/app/styles/components/auth.css
Normal file
126
apps/web/src/app/styles/components/auth.css
Normal file
@ -0,0 +1,126 @@
|
||||
/* ── Auth pages ──────────────────────────────────────────────────── */
|
||||
|
||||
.auth-page {
|
||||
height: 100vh;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
background: linear-gradient(135deg, var(--wing-bg) 0%, var(--wing-surface) 50%, var(--wing-bg) 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;
|
||||
}
|
||||
217
apps/web/src/app/styles/components/map-panels.css
Normal file
217
apps/web/src/app/styles/components/map-panels.css
Normal file
@ -0,0 +1,217 @@
|
||||
/* Map panels */
|
||||
.map-legend {
|
||||
position: absolute;
|
||||
/* Keep attribution visible (bottom-right) for licensing/compliance. */
|
||||
bottom: 44px;
|
||||
right: 12px;
|
||||
z-index: 800;
|
||||
background: var(--wing-glass);
|
||||
backdrop-filter: blur(8px);
|
||||
border: 1px solid var(--border);
|
||||
border-radius: 8px;
|
||||
padding: 10px;
|
||||
font-size: 9px;
|
||||
min-width: 180px;
|
||||
}
|
||||
|
||||
.map-legend .lt {
|
||||
font-size: 8px;
|
||||
font-weight: 700;
|
||||
color: var(--muted);
|
||||
margin-bottom: 4px;
|
||||
letter-spacing: 1px;
|
||||
}
|
||||
|
||||
.map-legend .li {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 5px;
|
||||
margin-bottom: 2px;
|
||||
}
|
||||
|
||||
.map-legend .ls {
|
||||
width: 12px;
|
||||
height: 12px;
|
||||
border-radius: 3px;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.map-info {
|
||||
position: absolute;
|
||||
top: 12px;
|
||||
right: 12px;
|
||||
z-index: 800;
|
||||
background: var(--wing-glass-dense);
|
||||
backdrop-filter: blur(8px);
|
||||
border: 1px solid var(--border);
|
||||
border-radius: 8px;
|
||||
padding: 12px;
|
||||
width: 270px;
|
||||
}
|
||||
|
||||
.map-info .ir {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
font-size: 10px;
|
||||
padding: 2px 0;
|
||||
border-bottom: 1px solid var(--wing-subtle);
|
||||
}
|
||||
|
||||
.map-info .il {
|
||||
color: var(--muted);
|
||||
}
|
||||
|
||||
.map-info .iv {
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
.map-loader-overlay {
|
||||
position: absolute;
|
||||
inset: 0;
|
||||
z-index: 950;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
background: var(--wing-overlay);
|
||||
pointer-events: auto;
|
||||
}
|
||||
|
||||
.map-loader-overlay__panel {
|
||||
width: min(72vw, 320px);
|
||||
background: var(--wing-glass-dense);
|
||||
border: 1px solid var(--border);
|
||||
border-radius: 12px;
|
||||
padding: 14px 16px;
|
||||
display: grid;
|
||||
gap: 10px;
|
||||
justify-items: center;
|
||||
}
|
||||
|
||||
.map-loader-overlay__spinner {
|
||||
width: 28px;
|
||||
height: 28px;
|
||||
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;
|
||||
}
|
||||
|
||||
.map-loader-overlay__text {
|
||||
font-size: 12px;
|
||||
color: var(--text);
|
||||
letter-spacing: 0.2px;
|
||||
}
|
||||
|
||||
.map-loader-overlay__bar {
|
||||
width: 100%;
|
||||
height: 6px;
|
||||
border-radius: 999px;
|
||||
background: rgba(148, 163, 184, 0.2);
|
||||
overflow: hidden;
|
||||
position: relative;
|
||||
}
|
||||
|
||||
.map-loader-overlay__fill {
|
||||
width: 28%;
|
||||
height: 100%;
|
||||
border-radius: inherit;
|
||||
background: var(--accent);
|
||||
animation: map-loader-fill 1.2s ease-in-out infinite;
|
||||
}
|
||||
|
||||
.maplibre-tooltip-popup .maplibregl-popup-content {
|
||||
color: #f8fafc !important;
|
||||
background: rgba(2, 6, 23, 0.98) !important;
|
||||
border: 1px solid rgba(148, 163, 184, 0.4) !important;
|
||||
box-shadow: 0 8px 26px rgba(2, 6, 23, 0.55) !important;
|
||||
border-radius: 8px !important;
|
||||
font-size: 11px !important;
|
||||
line-height: 1.35 !important;
|
||||
padding: 7px 9px !important;
|
||||
color: #f8fafc !important;
|
||||
min-width: 180px;
|
||||
}
|
||||
|
||||
.maplibre-tooltip-popup .maplibregl-popup-tip {
|
||||
border-top-color: rgba(2, 6, 23, 0.97) !important;
|
||||
}
|
||||
|
||||
.maplibre-tooltip-popup__content {
|
||||
color: #f8fafc;
|
||||
font-family: Pretendard, Inter, ui-sans-serif, -apple-system, Segoe UI, sans-serif;
|
||||
font-size: 11px;
|
||||
line-height: 1.35;
|
||||
}
|
||||
|
||||
.maplibre-tooltip-popup__content div,
|
||||
.maplibre-tooltip-popup__content span,
|
||||
.maplibre-tooltip-popup__content p {
|
||||
color: inherit;
|
||||
}
|
||||
|
||||
.maplibre-tooltip-popup__content div {
|
||||
word-break: break-word;
|
||||
}
|
||||
|
||||
.maplibre-tooltip-popup .maplibregl-popup-content div,
|
||||
.maplibre-tooltip-popup .maplibregl-popup-content span,
|
||||
.maplibre-tooltip-popup .maplibregl-popup-content p {
|
||||
color: inherit !important;
|
||||
}
|
||||
|
||||
.maplibre-tooltip-popup .maplibregl-popup-close-button {
|
||||
color: #94a3b8 !important;
|
||||
}
|
||||
|
||||
@keyframes map-loader-spin {
|
||||
to {
|
||||
transform: rotate(360deg);
|
||||
}
|
||||
}
|
||||
|
||||
@keyframes map-loader-fill {
|
||||
0% {
|
||||
transform: translateX(-40%);
|
||||
}
|
||||
|
||||
50% {
|
||||
transform: translateX(220%);
|
||||
}
|
||||
|
||||
100% {
|
||||
transform: translateX(-40%);
|
||||
}
|
||||
}
|
||||
|
||||
.maplibregl-ctrl-group {
|
||||
border: 1px solid var(--border) !important;
|
||||
background: var(--wing-glass) !important;
|
||||
backdrop-filter: blur(8px);
|
||||
}
|
||||
|
||||
.maplibregl-ctrl-group button {
|
||||
background: transparent !important;
|
||||
}
|
||||
|
||||
.maplibregl-ctrl-group button + button {
|
||||
border-top: 1px solid var(--border) !important;
|
||||
}
|
||||
|
||||
:root .maplibregl-ctrl-group button span,
|
||||
[data-theme='dark'] .maplibregl-ctrl-group button span {
|
||||
filter: invert(1);
|
||||
opacity: 0.9;
|
||||
}
|
||||
|
||||
[data-theme='light'] .maplibregl-ctrl-group button span {
|
||||
filter: none;
|
||||
opacity: 0.7;
|
||||
}
|
||||
|
||||
.maplibregl-ctrl-attrib {
|
||||
font-size: 10px !important;
|
||||
background: var(--wing-glass) !important;
|
||||
color: var(--text) !important;
|
||||
border: 1px solid var(--border) !important;
|
||||
border-radius: 8px;
|
||||
}
|
||||
175
apps/web/src/app/styles/components/map-settings.css
Normal file
175
apps/web/src/app/styles/components/map-settings.css
Normal file
@ -0,0 +1,175 @@
|
||||
/* ── 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: var(--wing-glass);
|
||||
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: var(--wing-glass-dense);
|
||||
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: var(--wing-glass);
|
||||
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;
|
||||
}
|
||||
56
apps/web/src/app/styles/components/panels.css
Normal file
56
apps/web/src/app/styles/components/panels.css
Normal file
@ -0,0 +1,56 @@
|
||||
.relation-sort {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 6px;
|
||||
font-size: 8px;
|
||||
color: var(--muted);
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.relation-sort__option {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 3px;
|
||||
cursor: pointer;
|
||||
user-select: none;
|
||||
}
|
||||
|
||||
.close-btn {
|
||||
position: absolute;
|
||||
top: 6px;
|
||||
right: 8px;
|
||||
background: none;
|
||||
border: none;
|
||||
color: var(--muted);
|
||||
cursor: pointer;
|
||||
font-size: 13px;
|
||||
}
|
||||
|
||||
.month-row {
|
||||
display: flex;
|
||||
gap: 1px;
|
||||
}
|
||||
|
||||
.month-cell {
|
||||
flex: 1;
|
||||
height: 12px;
|
||||
border-radius: 2px;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
font-size: 6px;
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
::-webkit-scrollbar {
|
||||
width: 3px;
|
||||
}
|
||||
|
||||
::-webkit-scrollbar-track {
|
||||
background: transparent;
|
||||
}
|
||||
|
||||
::-webkit-scrollbar-thumb {
|
||||
background: var(--border);
|
||||
border-radius: 2px;
|
||||
}
|
||||
95
apps/web/src/app/styles/components/relations.css
Normal file
95
apps/web/src/app/styles/components/relations.css
Normal file
@ -0,0 +1,95 @@
|
||||
/* Relation panel */
|
||||
.rel-panel {
|
||||
background: var(--card);
|
||||
border-radius: 6px;
|
||||
padding: 8px;
|
||||
margin-top: 4px;
|
||||
}
|
||||
|
||||
.rel-header {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 6px;
|
||||
margin-bottom: 6px;
|
||||
}
|
||||
|
||||
.rel-badge {
|
||||
font-size: 9px;
|
||||
padding: 1px 5px;
|
||||
border-radius: 3px;
|
||||
font-weight: 700;
|
||||
}
|
||||
|
||||
.rel-line {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 4px;
|
||||
font-size: 10px;
|
||||
padding: 2px 0;
|
||||
}
|
||||
|
||||
.rel-line .dot {
|
||||
width: 6px;
|
||||
height: 6px;
|
||||
border-radius: 50%;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.rel-link {
|
||||
width: 20px;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
color: var(--muted);
|
||||
font-size: 10px;
|
||||
}
|
||||
|
||||
.rel-dist {
|
||||
font-size: 8px;
|
||||
padding: 1px 4px;
|
||||
border-radius: 2px;
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
/* Fleet network */
|
||||
.fleet-card {
|
||||
background: var(--wing-card-alpha);
|
||||
border: 1px solid var(--border);
|
||||
border-radius: 6px;
|
||||
padding: 8px;
|
||||
margin-bottom: 4px;
|
||||
}
|
||||
|
||||
.fleet-card.hl,
|
||||
.fleet-card:hover {
|
||||
border-color: rgba(245, 158, 11, 0.75);
|
||||
background: rgba(251, 191, 36, 0.09);
|
||||
box-shadow: 0 0 0 1px rgba(245, 158, 11, 0.25) inset;
|
||||
}
|
||||
|
||||
.fleet-owner {
|
||||
font-size: 10px;
|
||||
font-weight: 700;
|
||||
color: var(--accent);
|
||||
margin-bottom: 4px;
|
||||
}
|
||||
|
||||
.fleet-owner.hl {
|
||||
color: rgba(245, 158, 11, 1);
|
||||
}
|
||||
|
||||
.fleet-vessel {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 4px;
|
||||
font-size: 9px;
|
||||
padding: 1px 0;
|
||||
}
|
||||
|
||||
.fleet-vessel.hl {
|
||||
color: rgba(245, 158, 11, 1);
|
||||
}
|
||||
|
||||
.fleet-dot.hl {
|
||||
box-shadow: 0 0 0 2px rgba(245, 158, 11, 0.45);
|
||||
}
|
||||
33
apps/web/src/app/styles/components/speed.css
Normal file
33
apps/web/src/app/styles/components/speed.css
Normal file
@ -0,0 +1,33 @@
|
||||
/* Speed bar */
|
||||
.sbar {
|
||||
position: relative;
|
||||
height: 24px;
|
||||
background: var(--bg);
|
||||
border-radius: 5px;
|
||||
overflow: hidden;
|
||||
margin: 4px 0;
|
||||
}
|
||||
|
||||
.sseg {
|
||||
position: absolute;
|
||||
border-radius: 3px;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
font-size: 7px;
|
||||
color: #fff;
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
.sseg.p {
|
||||
height: 24px;
|
||||
top: 0;
|
||||
border: 1.5px solid rgba(255, 255, 255, 0.25);
|
||||
box-shadow: 0 0 6px rgba(0, 0, 0, 0.3);
|
||||
}
|
||||
|
||||
.sseg:not(.p) {
|
||||
height: 16px;
|
||||
top: 4px;
|
||||
opacity: 0.6;
|
||||
}
|
||||
61
apps/web/src/app/styles/components/vessel-list.css
Normal file
61
apps/web/src/app/styles/components/vessel-list.css
Normal file
@ -0,0 +1,61 @@
|
||||
/* Vessel list */
|
||||
.vlist {
|
||||
flex: 1;
|
||||
min-height: 0;
|
||||
overflow-y: auto;
|
||||
}
|
||||
|
||||
.vi {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 6px;
|
||||
padding: 4px 6px;
|
||||
border-radius: 3px;
|
||||
cursor: pointer;
|
||||
font-size: 10px;
|
||||
transition: background 0.1s;
|
||||
user-select: none;
|
||||
}
|
||||
|
||||
.vi:hover {
|
||||
background: var(--card);
|
||||
}
|
||||
|
||||
.vi.sel {
|
||||
background: rgba(14, 234, 255, 0.16);
|
||||
border-color: rgba(14, 234, 255, 0.55);
|
||||
}
|
||||
|
||||
.vi.hl {
|
||||
background: rgba(245, 158, 11, 0.16);
|
||||
border: 1px solid rgba(245, 158, 11, 0.4);
|
||||
}
|
||||
|
||||
.vi.sel.hl {
|
||||
background: linear-gradient(90deg, rgba(14, 234, 255, 0.16), rgba(245, 158, 11, 0.16));
|
||||
border-color: rgba(14, 234, 255, 0.7);
|
||||
}
|
||||
|
||||
.vi .dot {
|
||||
width: 7px;
|
||||
height: 7px;
|
||||
border-radius: 50%;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.vi .nm {
|
||||
flex: 1;
|
||||
font-weight: 500;
|
||||
}
|
||||
|
||||
.vi .sp {
|
||||
font-weight: 700;
|
||||
font-size: 9px;
|
||||
}
|
||||
|
||||
.vi .st {
|
||||
font-size: 7px;
|
||||
padding: 1px 3px;
|
||||
border-radius: 2px;
|
||||
font-weight: 600;
|
||||
}
|
||||
356
apps/web/src/app/styles/components/weather-overlay.css
Normal file
356
apps/web/src/app/styles/components/weather-overlay.css
Normal file
@ -0,0 +1,356 @@
|
||||
/* ── 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: var(--wing-glass);
|
||||
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: var(--wing-glass-dense);
|
||||
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: var(--wing-subtle);
|
||||
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: var(--wing-glass);
|
||||
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;
|
||||
}
|
||||
185
apps/web/src/app/styles/components/weather.css
Normal file
185
apps/web/src/app/styles/components/weather.css
Normal file
@ -0,0 +1,185 @@
|
||||
/* ── Weather Panel (Open-Meteo) ───────────────────────────────────── */
|
||||
|
||||
.weather-gear {
|
||||
position: absolute;
|
||||
top: 140px;
|
||||
left: 10px;
|
||||
z-index: 850;
|
||||
width: 29px;
|
||||
height: 29px;
|
||||
border-radius: 4px;
|
||||
border: 1px solid var(--border);
|
||||
background: var(--wing-glass);
|
||||
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: var(--wing-glass-dense);
|
||||
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: var(--wing-subtle);
|
||||
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;
|
||||
}
|
||||
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;
|
||||
}
|
||||
@ -1,34 +0,0 @@
|
||||
import type { Vessel, VesselTypeCode } from "../model/types";
|
||||
|
||||
export function isTrawl(code: VesselTypeCode) {
|
||||
return code === "PT" || code === "PT-S";
|
||||
}
|
||||
|
||||
export function filterVesselsForMap(vessels: readonly Vessel[], selectedType: VesselTypeCode | null) {
|
||||
if (!selectedType) return vessels;
|
||||
|
||||
return vessels.filter((v) => {
|
||||
if (v.code === selectedType) return true;
|
||||
|
||||
// PT and PT-S should be shown together
|
||||
if (selectedType === "PT" && v.code === "PT-S") return true;
|
||||
if (selectedType === "PT-S" && v.code === "PT") return true;
|
||||
|
||||
// FC interacts with trawl; show trawl when FC selected and FC when trawl selected
|
||||
if (selectedType === "FC" && (v.code === "PT" || v.code === "PT-S")) return true;
|
||||
if ((selectedType === "PT" || selectedType === "PT-S") && v.code === "FC") return true;
|
||||
|
||||
return false;
|
||||
});
|
||||
}
|
||||
|
||||
export function filterVesselsForList(vessels: readonly Vessel[], selectedType: VesselTypeCode | null) {
|
||||
if (!selectedType) return vessels;
|
||||
return vessels.filter((v) => {
|
||||
if (v.code === selectedType) return true;
|
||||
if (selectedType === "PT" && v.code === "PT-S") return true;
|
||||
if (selectedType === "PT-S" && v.code === "PT") return true;
|
||||
return false;
|
||||
});
|
||||
}
|
||||
|
||||
@ -1,278 +0,0 @@
|
||||
import type { ZoneId } from "../../zone/model/meta";
|
||||
import { haversineNm } from "../../../shared/lib/geo/haversineNm";
|
||||
import { VESSEL_TYPES } from "./meta";
|
||||
import type { FleetOwner, FleetState, TrawlPair, Vessel, VesselTypeCode } from "./types";
|
||||
|
||||
const SURNAMES = ["张", "王", "李", "刘", "陈", "杨", "黄", "赵", "周", "吴", "徐", "孙", "马", "朱", "胡", "郭", "林", "何", "高", "罗"];
|
||||
const REGIONS = ["荣成", "石岛", "烟台", "威海", "日照", "青岛", "连云港", "舟山", "象山", "大连"];
|
||||
|
||||
const ZONE_BOUNDS: Record<ZoneId, { lat: [number, number]; lon: [number, number] }> = {
|
||||
"1": { lon: [128.85, 131.70], lat: [36.16, 38.25] },
|
||||
"2": { lon: [126.00, 128.90], lat: [32.18, 34.35] },
|
||||
"3": { lon: [124.12, 126.06], lat: [33.13, 35.00] },
|
||||
"4": { lon: [124.33, 125.85], lat: [35.00, 37.00] },
|
||||
};
|
||||
|
||||
function rnd(a: number, b: number) {
|
||||
return a + Math.random() * (b - a);
|
||||
}
|
||||
|
||||
function pick<T>(arr: readonly T[]) {
|
||||
return arr[Math.floor(Math.random() * arr.length)];
|
||||
}
|
||||
|
||||
function randomPointInZone(zone: ZoneId) {
|
||||
const b = ZONE_BOUNDS[zone];
|
||||
// Small margin to avoid sitting exactly on the edge.
|
||||
const lat = rnd(b.lat[0] + 0.05, b.lat[1] - 0.05);
|
||||
const lon = rnd(b.lon[0] + 0.05, b.lon[1] - 0.05);
|
||||
return { lat, lon };
|
||||
}
|
||||
|
||||
function makePermit(id: number, suffix: "A" | "B") {
|
||||
return `C21-${10000 + id}${suffix}`;
|
||||
}
|
||||
|
||||
export function createMockFleetState(): FleetState {
|
||||
const vessels: Vessel[] = [];
|
||||
const owners: FleetOwner[] = [];
|
||||
const ptPairs: TrawlPair[] = [];
|
||||
|
||||
let vid = 1;
|
||||
|
||||
// PT pairs: PT count == PT-S count, treat as "pair count".
|
||||
for (let i = 0; i < VESSEL_TYPES.PT.count; i++) {
|
||||
const owner = `${pick(SURNAMES)}${pick(SURNAMES)}${pick(["渔业", "海产", "水产", "船务"])}${pick(["有限公司", "合作社", ""])}`;
|
||||
const region = pick(REGIONS);
|
||||
const zone = pick<ZoneId>(["2", "3"]);
|
||||
const { lat, lon } = randomPointInZone(zone);
|
||||
|
||||
const isFishing = Math.random() < 0.55;
|
||||
const sp = isFishing ? rnd(2.5, 4.5) : rnd(6, 11);
|
||||
const crs = rnd(0, 360);
|
||||
|
||||
const pairDist = isFishing ? rnd(0.2, 1.2) : rnd(0, 0.3); // NM (rough)
|
||||
const pairAngle = rnd(0, 360);
|
||||
const lat2 = lat + (pairDist / 60) * Math.cos((pairAngle * Math.PI) / 180);
|
||||
const lon2 = lon + ((pairDist / 60) * Math.sin((pairAngle * Math.PI) / 180)) / Math.cos((lat * Math.PI) / 180);
|
||||
|
||||
const permitBase = vid;
|
||||
const ptId = vid++;
|
||||
const ptsId = vid++;
|
||||
|
||||
const pt: Vessel = {
|
||||
id: ptId,
|
||||
permit: makePermit(permitBase, "A"),
|
||||
code: "PT",
|
||||
color: VESSEL_TYPES.PT.color,
|
||||
lat,
|
||||
lon,
|
||||
speed: Number(sp.toFixed(1)),
|
||||
course: Number(crs.toFixed(0)),
|
||||
state: isFishing ? "조업중" : sp < 1 ? "정박" : "항해중",
|
||||
zone,
|
||||
isFishing,
|
||||
owner,
|
||||
region,
|
||||
pairId: null,
|
||||
pairDistNm: Number(pairDist.toFixed(2)),
|
||||
nearVesselIds: [],
|
||||
};
|
||||
|
||||
const pts: Vessel = {
|
||||
id: ptsId,
|
||||
permit: makePermit(permitBase, "B"),
|
||||
code: "PT-S",
|
||||
color: VESSEL_TYPES["PT-S"].color,
|
||||
lat: Number(lat2.toFixed(4)),
|
||||
lon: Number(lon2.toFixed(4)),
|
||||
speed: Number((sp + rnd(-0.3, 0.3)).toFixed(1)),
|
||||
course: Number((crs + rnd(-10, 10)).toFixed(0)),
|
||||
state: isFishing ? "조업중" : sp < 1 ? "정박" : "항해중",
|
||||
zone,
|
||||
isFishing,
|
||||
owner,
|
||||
region,
|
||||
pairId: null,
|
||||
pairDistNm: pt.pairDistNm,
|
||||
nearVesselIds: [],
|
||||
};
|
||||
|
||||
pt.pairId = pts.id;
|
||||
pts.pairId = pt.id;
|
||||
|
||||
vessels.push(pt, pts);
|
||||
ptPairs.push({ mainId: pt.id, subId: pts.id, owner, region });
|
||||
owners.push({ name: owner, region, vessels: [pt.id, pts.id], type: "trawl" });
|
||||
}
|
||||
|
||||
// GN vessels
|
||||
for (let i = 0; i < VESSEL_TYPES.GN.count; i++) {
|
||||
const attachToOwner = Math.random() < 0.3 ? owners[Math.floor(Math.random() * owners.length)] : null;
|
||||
const owner = attachToOwner ? attachToOwner.name : `${pick(SURNAMES)}${pick(SURNAMES)}${pick(["渔业", "水产"])}有限公司`;
|
||||
const region = attachToOwner ? attachToOwner.region : pick(REGIONS);
|
||||
const zone = pick<ZoneId>(["2", "3", "4"]);
|
||||
const { lat, lon } = randomPointInZone(zone);
|
||||
|
||||
const isFishing = Math.random() < 0.5;
|
||||
const sp = isFishing ? rnd(0.5, 2) : rnd(5, 10);
|
||||
|
||||
const id = vid++;
|
||||
const v: Vessel = {
|
||||
id,
|
||||
permit: makePermit(id, "A"),
|
||||
code: "GN",
|
||||
color: VESSEL_TYPES.GN.color,
|
||||
lat,
|
||||
lon,
|
||||
speed: Number(sp.toFixed(1)),
|
||||
course: Number(rnd(0, 360).toFixed(0)),
|
||||
state: isFishing ? pick(["표류", "투망", "양망"]) : sp < 1 ? "정박" : "항해중",
|
||||
zone,
|
||||
isFishing,
|
||||
owner,
|
||||
region,
|
||||
pairId: null,
|
||||
pairDistNm: null,
|
||||
nearVesselIds: [],
|
||||
};
|
||||
|
||||
vessels.push(v);
|
||||
if (attachToOwner) attachToOwner.vessels.push(v.id);
|
||||
else owners.push({ name: owner, region, vessels: [v.id], type: "gn" });
|
||||
}
|
||||
|
||||
// OT
|
||||
for (let i = 0; i < VESSEL_TYPES.OT.count; i++) {
|
||||
const owner = `${pick(SURNAMES)}${pick(SURNAMES)}远洋渔业`;
|
||||
const region = pick(REGIONS);
|
||||
const zone = pick<ZoneId>(["2", "3"]);
|
||||
const { lat, lon } = randomPointInZone(zone);
|
||||
const isFishing = Math.random() < 0.5;
|
||||
const sp = isFishing ? rnd(2.5, 5) : rnd(5, 10);
|
||||
const id = vid++;
|
||||
const v: Vessel = {
|
||||
id,
|
||||
permit: makePermit(id, "A"),
|
||||
code: "OT",
|
||||
color: VESSEL_TYPES.OT.color,
|
||||
lat,
|
||||
lon,
|
||||
speed: Number(sp.toFixed(1)),
|
||||
course: Number(rnd(0, 360).toFixed(0)),
|
||||
state: isFishing ? "조업중" : "항해중",
|
||||
zone,
|
||||
isFishing,
|
||||
owner,
|
||||
region,
|
||||
pairId: null,
|
||||
pairDistNm: null,
|
||||
nearVesselIds: [],
|
||||
};
|
||||
vessels.push(v);
|
||||
owners.push({ name: owner, region, vessels: [v.id], type: "ot" });
|
||||
}
|
||||
|
||||
// PS
|
||||
for (let i = 0; i < VESSEL_TYPES.PS.count; i++) {
|
||||
const owner = `${pick(SURNAMES)}${pick(SURNAMES)}水产`;
|
||||
const region = pick(REGIONS);
|
||||
const zone = pick<ZoneId>(["1", "2", "3", "4"]);
|
||||
const { lat, lon } = randomPointInZone(zone);
|
||||
const isFishing = Math.random() < 0.5;
|
||||
const sp = isFishing ? rnd(0.3, 1.5) : rnd(5, 9);
|
||||
const id = vid++;
|
||||
const v: Vessel = {
|
||||
id,
|
||||
permit: makePermit(id, "A"),
|
||||
code: "PS",
|
||||
color: VESSEL_TYPES.PS.color,
|
||||
lat,
|
||||
lon,
|
||||
speed: Number(sp.toFixed(1)),
|
||||
course: Number(rnd(0, 360).toFixed(0)),
|
||||
state: isFishing ? pick(["위망", "채낚기"]) : "항해중",
|
||||
zone,
|
||||
isFishing,
|
||||
owner,
|
||||
region,
|
||||
pairId: null,
|
||||
pairDistNm: null,
|
||||
nearVesselIds: [],
|
||||
};
|
||||
vessels.push(v);
|
||||
owners.push({ name: owner, region, vessels: [v.id], type: "ps" });
|
||||
}
|
||||
|
||||
// FC — assigned to trawl owners (positioned near PT)
|
||||
const trawlOwners = owners.filter((o) => o.type === "trawl");
|
||||
for (let i = 0; i < VESSEL_TYPES.FC.count; i++) {
|
||||
const oi = i < trawlOwners.length ? trawlOwners[i] : pick(trawlOwners);
|
||||
|
||||
const refId = oi.vessels.find((id) => vessels[id - 1]?.code === "PT") ?? oi.vessels[0];
|
||||
const ref = vessels[refId - 1];
|
||||
|
||||
const zone = pick<ZoneId>(["2", "3"]);
|
||||
const lat = ref.lat + rnd(-0.2, 0.2);
|
||||
const lon = ref.lon + rnd(-0.2, 0.2);
|
||||
|
||||
const isNear = Math.random() < 0.4;
|
||||
const sp = isNear ? rnd(0.5, 1.5) : rnd(5, 9);
|
||||
|
||||
const nearVesselIds = isNear ? oi.vessels.filter((id) => vessels[id - 1]?.code !== "FC").slice(0, 2) : [];
|
||||
|
||||
const v: Vessel = {
|
||||
id: vid,
|
||||
permit: makePermit(vid, "A"),
|
||||
code: "FC",
|
||||
color: VESSEL_TYPES.FC.color,
|
||||
lat,
|
||||
lon,
|
||||
speed: Number(sp.toFixed(1)),
|
||||
course: Number(rnd(0, 360).toFixed(0)),
|
||||
state: isNear ? "환적" : "항해중",
|
||||
zone,
|
||||
isFishing: isNear, // kept from prototype: treat "환적" as fishing-like activity
|
||||
owner: oi.name,
|
||||
region: oi.region,
|
||||
pairId: null,
|
||||
pairDistNm: null,
|
||||
nearVesselIds,
|
||||
};
|
||||
vid += 1;
|
||||
vessels.push(v);
|
||||
oi.vessels.push(v.id);
|
||||
}
|
||||
|
||||
// Ensure initial pair distances are consistent with actual coordinates.
|
||||
for (const p of ptPairs) {
|
||||
const a = vessels[p.mainId - 1];
|
||||
const b = vessels[p.subId - 1];
|
||||
const d = haversineNm(a.lat, a.lon, b.lat, b.lon);
|
||||
a.pairDistNm = d;
|
||||
b.pairDistNm = d;
|
||||
}
|
||||
|
||||
return { vessels, owners, ptPairs };
|
||||
}
|
||||
|
||||
export function tickMockFleetState(state: FleetState) {
|
||||
for (const v of state.vessels) {
|
||||
v.lat += (0.5 - Math.random()) * 0.003;
|
||||
v.lon += (0.5 - Math.random()) * 0.003;
|
||||
v.speed = Math.max(0, Number((v.speed + (0.5 - Math.random()) * 0.4).toFixed(1)));
|
||||
v.course = Number(((v.course + (0.5 - Math.random()) * 6 + 360) % 360).toFixed(0));
|
||||
}
|
||||
|
||||
for (const p of state.ptPairs) {
|
||||
const a = state.vessels[p.mainId - 1];
|
||||
const b = state.vessels[p.subId - 1];
|
||||
const d = haversineNm(a.lat, a.lon, b.lat, b.lon);
|
||||
a.pairDistNm = d;
|
||||
b.pairDistNm = d;
|
||||
}
|
||||
}
|
||||
|
||||
export function isVesselCode(code: string): code is VesselTypeCode {
|
||||
return code === "PT" || code === "PT-S" || code === "GN" || code === "OT" || code === "PS" || code === "FC";
|
||||
}
|
||||
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 { searchAisTargets } from "../../entities/aisTarget/api/searchAisTargets";
|
||||
import { searchChnprmship } from "../../entities/aisTarget/api/searchChnprmship";
|
||||
import type { AisTarget } from "../../entities/aisTarget/model/types";
|
||||
|
||||
export type AisPollingStatus = "idle" | "loading" | "ready" | "error";
|
||||
@ -17,14 +18,21 @@ export type AisPollingSnapshot = {
|
||||
};
|
||||
|
||||
export type AisPollingOptions = {
|
||||
initialMinutes?: number;
|
||||
bootstrapMinutes?: number;
|
||||
/** 초기 chnprmship API 호출 시 minutes (기본 120) */
|
||||
chnprmshipMinutes?: number;
|
||||
/** 주기적 폴링 시 search API minutes (기본 2) */
|
||||
incrementalMinutes?: number;
|
||||
/** 폴링 주기 ms (기본 60_000) */
|
||||
intervalMs?: number;
|
||||
/** 보존 기간 (기본 chnprmshipMinutes) */
|
||||
retentionMinutes?: number;
|
||||
/** incremental 폴링 시 bbox 필터 */
|
||||
bbox?: string;
|
||||
/** incremental 폴링 시 중심 경도 */
|
||||
centerLon?: number;
|
||||
/** incremental 폴링 시 중심 위도 */
|
||||
centerLat?: number;
|
||||
/** incremental 폴링 시 반경(m) */
|
||||
radiusMeters?: number;
|
||||
enabled?: boolean;
|
||||
};
|
||||
@ -112,11 +120,10 @@ function pruneStore(store: Map<number, AisTarget>, retentionMinutes: number, bbo
|
||||
}
|
||||
|
||||
export function useAisTargetPolling(opts: AisPollingOptions = {}) {
|
||||
const initialMinutes = opts.initialMinutes ?? 60;
|
||||
const bootstrapMinutes = opts.bootstrapMinutes ?? initialMinutes;
|
||||
const incrementalMinutes = opts.incrementalMinutes ?? 1;
|
||||
const chnprmshipMinutes = opts.chnprmshipMinutes ?? 120;
|
||||
const incrementalMinutes = opts.incrementalMinutes ?? 2;
|
||||
const intervalMs = opts.intervalMs ?? 60_000;
|
||||
const retentionMinutes = opts.retentionMinutes ?? initialMinutes;
|
||||
const retentionMinutes = opts.retentionMinutes ?? chnprmshipMinutes;
|
||||
const enabled = opts.enabled ?? true;
|
||||
const bbox = opts.bbox;
|
||||
const centerLon = opts.centerLon;
|
||||
@ -146,50 +153,60 @@ export function useAisTargetPolling(opts: AisPollingOptions = {}) {
|
||||
const controller = new AbortController();
|
||||
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 {
|
||||
setSnapshot((s) => ({ ...s, status: s.status === "idle" ? "loading" : s.status, error: null }));
|
||||
|
||||
const res = await searchAisTargets(
|
||||
{
|
||||
minutes,
|
||||
bbox,
|
||||
centerLon,
|
||||
centerLat,
|
||||
radiusMeters,
|
||||
},
|
||||
controller.signal,
|
||||
);
|
||||
if (cancelled || generation !== generationRef.current) return;
|
||||
|
||||
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);
|
||||
setSnapshot((s) => ({ ...s, status: "loading", error: null }));
|
||||
const res = await searchChnprmship({ minutes }, controller.signal);
|
||||
applyResult(res, minutes);
|
||||
} catch (e) {
|
||||
if (cancelled || generation !== generationRef.current) return;
|
||||
setSnapshot((s) => ({
|
||||
...s,
|
||||
status: context === "incremental" ? s.status : "error",
|
||||
status: "error",
|
||||
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();
|
||||
setSnapshot({
|
||||
status: "loading",
|
||||
@ -204,12 +221,11 @@ export function useAisTargetPolling(opts: AisPollingOptions = {}) {
|
||||
});
|
||||
setRev((r) => r + 1);
|
||||
|
||||
void run(bootstrapMinutes, "bootstrap");
|
||||
if (bootstrapMinutes !== initialMinutes) {
|
||||
void run(initialMinutes, "initial");
|
||||
}
|
||||
// 초기 로드: chnprmship API 1회 호출
|
||||
void runInitial(chnprmshipMinutes);
|
||||
|
||||
const id = window.setInterval(() => void run(incrementalMinutes, "incremental"), intervalMs);
|
||||
// 주기적 폴링: search API로 incremental 업데이트
|
||||
const id = window.setInterval(() => void runIncremental(incrementalMinutes), intervalMs);
|
||||
|
||||
return () => {
|
||||
cancelled = true;
|
||||
@ -217,8 +233,7 @@ export function useAisTargetPolling(opts: AisPollingOptions = {}) {
|
||||
window.clearInterval(id);
|
||||
};
|
||||
}, [
|
||||
initialMinutes,
|
||||
bootstrapMinutes,
|
||||
chnprmshipMinutes,
|
||||
incrementalMinutes,
|
||||
intervalMs,
|
||||
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;
|
||||
}
|
||||
@ -1,3 +1,4 @@
|
||||
import { ToggleButton } from '@wing/ui';
|
||||
import type { Map3DSettings } from "../../widgets/map3d/Map3D";
|
||||
|
||||
type Props = {
|
||||
@ -13,11 +14,11 @@ export function Map3DSettingsToggles({ value, onToggle }: Props) {
|
||||
];
|
||||
|
||||
return (
|
||||
<div className="tog">
|
||||
<div className="flex flex-wrap gap-0.75 mb-1.5">
|
||||
{items.map((t) => (
|
||||
<div key={t.id} className={`tog-btn ${value[t.id] ? "on" : ""}`} onClick={() => onToggle(t.id)}>
|
||||
<ToggleButton key={t.id} on={value[t.id]} onClick={() => onToggle(t.id)}>
|
||||
{t.label}
|
||||
</div>
|
||||
</ToggleButton>
|
||||
))}
|
||||
</div>
|
||||
);
|
||||
|
||||
@ -146,8 +146,7 @@ export function MapSettingsPanel({ value, onChange }: MapSettingsPanelProps) {
|
||||
<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 }}
|
||||
className={`ml-2 cursor-pointer rounded border px-1.5 py-px text-[8px] transition-all duration-150 select-none ${autoGradient ? 'border-wing-accent bg-wing-accent text-white' : 'border-wing-border bg-wing-card text-wing-muted'}`}
|
||||
onClick={toggleAutoGradient}
|
||||
title="최소/최대 색상 기준으로 중간 구간을 자동 보간합니다"
|
||||
>
|
||||
@ -176,11 +175,11 @@ export function MapSettingsPanel({ value, onChange }: MapSettingsPanelProps) {
|
||||
{/* ── Depth font size ───────────────────────────── */}
|
||||
<div className="ms-section">
|
||||
<div className="ms-label">수심 폰트 크기</div>
|
||||
<div className="tog" style={{ gap: 3 }}>
|
||||
<div className="flex flex-wrap gap-0.75">
|
||||
{FONT_SIZES.map((fs) => (
|
||||
<div
|
||||
key={fs.value}
|
||||
className={`tog-btn${value.depthFontSize === fs.value ? ' on' : ''}`}
|
||||
className={`cursor-pointer rounded border px-1.5 py-0.5 text-[8px] transition-all duration-150 select-none ${value.depthFontSize === fs.value ? 'border-wing-accent bg-wing-accent text-white' : 'border-wing-border bg-wing-card text-wing-muted'}`}
|
||||
onClick={() => update('depthFontSize', fs.value)}
|
||||
>
|
||||
{fs.label}
|
||||
|
||||
@ -1,3 +1,5 @@
|
||||
import { ToggleButton } from '@wing/ui';
|
||||
|
||||
export type MapToggleState = {
|
||||
pairLines: boolean;
|
||||
pairRange: boolean;
|
||||
@ -27,11 +29,16 @@ export function MapToggles({ value, onToggle }: Props) {
|
||||
];
|
||||
|
||||
return (
|
||||
<div className="tog tog-map">
|
||||
<div className="flex flex-wrap gap-1">
|
||||
{items.map((t) => (
|
||||
<div key={t.id} className={`tog-btn ${value[t.id] ? "on" : ""}`} onClick={() => onToggle(t.id)}>
|
||||
<ToggleButton
|
||||
key={t.id}
|
||||
on={value[t.id]}
|
||||
onClick={() => onToggle(t.id)}
|
||||
className="flex-[1_1_calc(25%-4px)] overflow-hidden text-center text-ellipsis whitespace-nowrap"
|
||||
>
|
||||
{t.label}
|
||||
</div>
|
||||
</ToggleButton>
|
||||
))}
|
||||
</div>
|
||||
);
|
||||
|
||||
@ -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 '../../../shared/lib/map/mapConstants';
|
||||
|
||||
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 '../../../shared/lib/map/mapConstants';
|
||||
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,
|
||||
}));
|
||||
},
|
||||
}));
|
||||
@ -9,26 +9,26 @@ type Props = {
|
||||
onToggleAll: () => void;
|
||||
};
|
||||
|
||||
const TB = "cursor-pointer rounded-[5px] border p-1 text-center transition-all duration-150 select-none";
|
||||
const TB_ON = "border-wing-accent bg-wing-accent/10";
|
||||
const TB_OFF = "border-transparent bg-wing-card hover:border-wing-border";
|
||||
|
||||
export function TypeFilterGrid({ enabled, totalCount, countsByType, onToggle, onToggleAll }: Props) {
|
||||
const allOn = VESSEL_TYPE_ORDER.every((c) => enabled[c]);
|
||||
|
||||
return (
|
||||
<div className="tg">
|
||||
<div className={`tb ${allOn ? "on" : ""}`} onClick={onToggleAll} style={{ gridColumn: "1/-1" }}>
|
||||
<div className="c" style={{ color: "var(--accent)" }}>
|
||||
전체
|
||||
</div>
|
||||
<div className="n">{totalCount}척</div>
|
||||
<div className="grid grid-cols-3 gap-0.75">
|
||||
<div className={`col-span-full ${TB} ${allOn ? TB_ON : TB_OFF}`} onClick={onToggleAll}>
|
||||
<div className="text-[11px] font-extrabold text-wing-accent">전체</div>
|
||||
<div className="text-[8px] text-wing-muted">{totalCount}척</div>
|
||||
</div>
|
||||
{VESSEL_TYPE_ORDER.map((code) => {
|
||||
const t = VESSEL_TYPES[code];
|
||||
const cnt = countsByType[code] ?? 0;
|
||||
return (
|
||||
<div key={code} className={`tb ${enabled[code] ? "on" : ""}`} onClick={() => onToggle(code)}>
|
||||
<div className="c" style={{ color: t.color }}>
|
||||
{code}
|
||||
</div>
|
||||
<div className="n">{cnt}척</div>
|
||||
<div key={code} className={`${TB} ${enabled[code] ? TB_ON : TB_OFF}`} onClick={() => onToggle(code)}>
|
||||
<div className="text-[11px] font-extrabold" style={{ color: t.color }}>{code}</div>
|
||||
<div className="text-[8px] text-wing-muted">{cnt}척</div>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
|
||||
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 '../../shared/lib/map/mapTilerKey';
|
||||
|
||||
/** 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,34 +1,31 @@
|
||||
import { useEffect, useMemo, useRef, useState } from "react";
|
||||
import { useCallback, useMemo, useState } from "react";
|
||||
import { useAuth } from "../../shared/auth";
|
||||
import { useTheme } from "../../shared/hooks";
|
||||
import { useAisTargetPolling } from "../../features/aisPolling/useAisTargetPolling";
|
||||
import { Map3DSettingsToggles } from "../../features/map3dSettings/Map3DSettingsToggles";
|
||||
import type { MapToggleState } from "../../features/mapToggles/MapToggles";
|
||||
import { MapToggles } from "../../features/mapToggles/MapToggles";
|
||||
import { TypeFilterGrid } from "../../features/typeFilter/TypeFilterGrid";
|
||||
import type { DerivedLegacyVessel, LegacyAlarmKind } from "../../features/legacyDashboard/model/types";
|
||||
import { LEGACY_ALARM_KIND_LABEL, LEGACY_ALARM_KINDS } from "../../features/legacyDashboard/model/types";
|
||||
import type { DerivedLegacyVessel } from "../../features/legacyDashboard/model/types";
|
||||
import { LEGACY_ALARM_KINDS } from "../../features/legacyDashboard/model/types";
|
||||
import { useLegacyVessels } from "../../entities/legacyVessel/api/useLegacyVessels";
|
||||
import { buildLegacyVesselIndex } from "../../entities/legacyVessel/lib";
|
||||
import type { LegacyVesselIndex } from "../../entities/legacyVessel/lib";
|
||||
import type { LegacyVesselDataset } from "../../entities/legacyVessel/model/types";
|
||||
import type { VesselTypeCode } from "../../entities/vessel/model/types";
|
||||
import { VESSEL_TYPE_ORDER } from "../../entities/vessel/model/meta";
|
||||
import { useZones } from "../../entities/zone/api/useZones";
|
||||
import { useSubcables } from "../../entities/subcable/api/useSubcables";
|
||||
import type { VesselTypeCode } from "../../entities/vessel/model/types";
|
||||
import { AisInfoPanel } from "../../widgets/aisInfo/AisInfoPanel";
|
||||
import { AisTargetList } from "../../widgets/aisTargetList/AisTargetList";
|
||||
import { AlarmsPanel } from "../../widgets/alarms/AlarmsPanel";
|
||||
import { MapLegend } from "../../widgets/legend/MapLegend";
|
||||
import { Map3D, type BaseMapId, type Map3DSettings, type MapProjectionId } from "../../widgets/map3d/Map3D";
|
||||
import { RelationsPanel } from "../../widgets/relations/RelationsPanel";
|
||||
import { SpeedProfilePanel } from "../../widgets/speed/SpeedProfilePanel";
|
||||
import { Map3D } from "../../widgets/map3d/Map3D";
|
||||
import { Topbar } from "../../widgets/topbar/Topbar";
|
||||
import { VesselInfoPanel } from "../../widgets/info/VesselInfoPanel";
|
||||
import { SubcableInfoPanel } from "../../widgets/subcableInfo/SubcableInfoPanel";
|
||||
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 { 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 { MapSettingsPanel } from "../../features/mapSettings/MapSettingsPanel";
|
||||
import {
|
||||
buildLegacyHitMap,
|
||||
computeCountsByType,
|
||||
@ -39,25 +36,16 @@ import {
|
||||
deriveLegacyVessels,
|
||||
filterByShipCodes,
|
||||
} from "../../features/legacyDashboard/model/derive";
|
||||
import { VESSEL_TYPE_ORDER } from "../../entities/vessel/model/meta";
|
||||
import { useDashboardState } from "./useDashboardState";
|
||||
import type { Bbox } from "./useDashboardState";
|
||||
import { DashboardSidebar } from "./DashboardSidebar";
|
||||
|
||||
const AIS_API_BASE = (import.meta.env.VITE_API_URL || "/snp-api").replace(/\/$/, "");
|
||||
const AIS_CENTER = {
|
||||
lon: 126.95,
|
||||
lat: 35.95,
|
||||
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 FleetRelationSortMode = "count" | "range";
|
||||
|
||||
function inBbox(lon: number, lat: number, bbox: Bbox) {
|
||||
const [lonMin, latMin, lonMax, latMax] = bbox;
|
||||
if (lat < latMin || lat > latMax) return false;
|
||||
@ -65,110 +53,94 @@ function inBbox(lon: number, lat: number, bbox: Bbox) {
|
||||
return lon >= lonMin || lon <= lonMax;
|
||||
}
|
||||
|
||||
function fmtBbox(b: Bbox | null) {
|
||||
if (!b) return "-";
|
||||
return `${b[0].toFixed(4)},${b[1].toFixed(4)},${b[2].toFixed(4)},${b[3].toFixed(4)}`;
|
||||
}
|
||||
|
||||
function useLegacyIndex(data: LegacyVesselDataset | null): LegacyVesselIndex | null {
|
||||
function useLegacyIndex(data: LegacyVesselDataset | null) {
|
||||
return useMemo(() => (data ? buildLegacyVesselIndex(data.vessels) : null), [data]);
|
||||
}
|
||||
|
||||
export function DashboardPage() {
|
||||
const { user, logout } = useAuth();
|
||||
const { theme, toggleTheme } = useTheme();
|
||||
const [isSidebarOpen, setIsSidebarOpen] = useState(false);
|
||||
const uid = user?.id ?? null;
|
||||
|
||||
// ── Data fetching ──
|
||||
const { data: zones, error: zonesError } = useZones();
|
||||
const { data: legacyData, error: legacyError } = useLegacyVessels();
|
||||
const { data: subcableData } = useSubcables();
|
||||
const legacyIndex = useLegacyIndex(legacyData);
|
||||
|
||||
const [viewBbox, setViewBbox] = useState<Bbox | null>(null);
|
||||
const [useViewportFilter, setUseViewportFilter] = useState(false);
|
||||
const [useApiBbox, setUseApiBbox] = useState(false);
|
||||
const [apiBbox, setApiBbox] = useState<string | undefined>(undefined);
|
||||
// ── UI state ──
|
||||
const state = useDashboardState(uid);
|
||||
const {
|
||||
mapInstance, handleMapReady,
|
||||
viewBbox, setViewBbox,
|
||||
useViewportFilter,
|
||||
useApiBbox, apiBbox,
|
||||
selectedMmsi, setSelectedMmsi,
|
||||
highlightedMmsiSet,
|
||||
hoveredMmsiSet, setHoveredMmsiSet,
|
||||
hoveredFleetMmsiSet, setHoveredFleetMmsiSet,
|
||||
hoveredPairMmsiSet, setHoveredPairMmsiSet,
|
||||
hoveredFleetOwnerKey, setHoveredFleetOwnerKey,
|
||||
typeEnabled,
|
||||
showTargets, showOthers,
|
||||
baseMap, projection,
|
||||
mapStyleSettings, setMapStyleSettings,
|
||||
overlays, settings,
|
||||
mapView, setMapView,
|
||||
fleetFocus, setFleetFocus,
|
||||
hoveredCableId, setHoveredCableId,
|
||||
selectedCableId, setSelectedCableId,
|
||||
trackContextMenu, handleOpenTrackMenu, handleCloseTrackMenu,
|
||||
handleProjectionLoadingChange,
|
||||
setIsGlobeShipsReady,
|
||||
showMapLoader,
|
||||
clock, adminMode, onLogoClick,
|
||||
setUniqueSorted, setSortedIfChanged, toggleHighlightedMmsi,
|
||||
alarmKindEnabled,
|
||||
} = state;
|
||||
|
||||
// ── Weather ──
|
||||
const weather = useWeatherPolling(zones);
|
||||
const weatherOverlay = useWeatherOverlay(mapInstance);
|
||||
|
||||
// ── AIS polling ──
|
||||
const { targets, snapshot } = useAisTargetPolling({
|
||||
initialMinutes: 60,
|
||||
bootstrapMinutes: 10,
|
||||
chnprmshipMinutes: 120,
|
||||
incrementalMinutes: 2,
|
||||
intervalMs: 60_000,
|
||||
retentionMinutes: 90,
|
||||
retentionMinutes: 120,
|
||||
bbox: useApiBbox ? apiBbox : undefined,
|
||||
centerLon: useApiBbox ? undefined : AIS_CENTER.lon,
|
||||
centerLat: useApiBbox ? undefined : AIS_CENTER.lat,
|
||||
radiusMeters: useApiBbox ? undefined : AIS_CENTER.radiusMeters,
|
||||
});
|
||||
|
||||
const [selectedMmsi, setSelectedMmsi] = useState<number | null>(null);
|
||||
const [highlightedMmsiSet, setHighlightedMmsiSet] = useState<number[]>([]);
|
||||
const [hoveredMmsiSet, setHoveredMmsiSet] = useState<number[]>([]);
|
||||
const [hoveredFleetMmsiSet, setHoveredFleetMmsiSet] = useState<number[]>([]);
|
||||
const [hoveredPairMmsiSet, setHoveredPairMmsiSet] = useState<number[]>([]);
|
||||
const [hoveredFleetOwnerKey, setHoveredFleetOwnerKey] = useState<string | null>(null);
|
||||
const [typeEnabled, setTypeEnabled] = useState<Record<VesselTypeCode, boolean>>({
|
||||
PT: true,
|
||||
"PT-S": true,
|
||||
GN: true,
|
||||
OT: true,
|
||||
PS: true,
|
||||
FC: true,
|
||||
});
|
||||
const [showTargets, setShowTargets] = useState(true);
|
||||
const [showOthers, setShowOthers] = useState(false);
|
||||
// ── Track request ──
|
||||
const handleRequestTrack = useCallback(async (mmsi: number, minutes: number) => {
|
||||
const trackStore = useTrackQueryStore.getState();
|
||||
const queryKey = `${mmsi}:${minutes}:${Date.now()}`;
|
||||
trackStore.beginQuery(queryKey);
|
||||
|
||||
// 레거시 베이스맵 비활성 — 향후 위성/라이트 등 추가 시 재활용
|
||||
// eslint-disable-next-line @typescript-eslint/no-unused-vars
|
||||
const [baseMap, _setBaseMap] = useState<BaseMapId>("enhanced");
|
||||
const [projection, setProjection] = useState<MapProjectionId>("mercator");
|
||||
const [mapStyleSettings, setMapStyleSettings] = useState<MapStyleSettings>(DEFAULT_MAP_STYLE_SETTINGS);
|
||||
try {
|
||||
const target = targets.find((item) => item.mmsi === mmsi);
|
||||
const tracks = await queryTrackByMmsi({
|
||||
mmsi,
|
||||
minutes,
|
||||
shipNameHint: target?.name,
|
||||
});
|
||||
|
||||
const [overlays, setOverlays] = useState<MapToggleState>({
|
||||
pairLines: true,
|
||||
pairRange: true,
|
||||
fcLines: true,
|
||||
zones: true,
|
||||
fleetCircles: true,
|
||||
predictVectors: true,
|
||||
shipLabels: true,
|
||||
subcables: false,
|
||||
});
|
||||
const [fleetRelationSortMode, setFleetRelationSortMode] = useState<FleetRelationSortMode>("count");
|
||||
|
||||
const [alarmKindEnabled, setAlarmKindEnabled] = useState<Record<LegacyAlarmKind, boolean>>(() => {
|
||||
return 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 [hoveredCableId, setHoveredCableId] = useState<string | null>(null);
|
||||
const [selectedCableId, setSelectedCableId] = useState<string | null>(null);
|
||||
|
||||
const [settings, setSettings] = useState<Map3DSettings>({
|
||||
showShips: true,
|
||||
showDensity: false,
|
||||
showSeamark: false,
|
||||
});
|
||||
|
||||
const [isProjectionLoading, setIsProjectionLoading] = useState(false);
|
||||
|
||||
const [clock, setClock] = useState(() => new Date().toLocaleString("ko-KR", { hour12: false }));
|
||||
useEffect(() => {
|
||||
const id = window.setInterval(() => setClock(new Date().toLocaleString("ko-KR", { hour12: false })), 1000);
|
||||
return () => window.clearInterval(id);
|
||||
}, []);
|
||||
|
||||
// Secret admin toggle: 7 clicks within 900ms on the logo.
|
||||
const [adminMode, setAdminMode] = useState(false);
|
||||
const clicksRef = useRef<number[]>([]);
|
||||
const onLogoClick = () => {
|
||||
const now = Date.now();
|
||||
clicksRef.current = clicksRef.current.filter((t) => now - t < 900);
|
||||
clicksRef.current.push(now);
|
||||
if (clicksRef.current.length >= 7) {
|
||||
clicksRef.current = [];
|
||||
setAdminMode((v) => !v);
|
||||
if (tracks.length > 0) {
|
||||
trackStore.applyTracksSuccess(tracks, queryKey);
|
||||
} else {
|
||||
trackStore.applyQueryError('항적 데이터가 없습니다.', queryKey);
|
||||
}
|
||||
} catch (e) {
|
||||
trackStore.applyQueryError(e instanceof Error ? e.message : '항적 조회에 실패했습니다.', queryKey);
|
||||
}
|
||||
};
|
||||
}, [targets]);
|
||||
|
||||
// ── Derived data ──
|
||||
const targetsInScope = useMemo(() => {
|
||||
if (!useViewportFilter || !viewBbox) return targets;
|
||||
return targets.filter((t) => typeof t.lon === "number" && typeof t.lat === "number" && inBbox(t.lon, t.lat, viewBbox));
|
||||
@ -207,17 +179,14 @@ export function DashboardPage() {
|
||||
const alarms = useMemo(() => computeLegacyAlarms({ vessels: legacyVesselsAll, pairLinks: pairLinksAll, fcLinks: fcLinksAll }), [legacyVesselsAll, pairLinksAll, fcLinksAll]);
|
||||
|
||||
const alarmKindCounts = useMemo(() => {
|
||||
const base = Object.fromEntries(LEGACY_ALARM_KINDS.map((k) => [k, 0])) as Record<LegacyAlarmKind, number>;
|
||||
const base = Object.fromEntries(LEGACY_ALARM_KINDS.map((k) => [k, 0])) as Record<typeof LEGACY_ALARM_KINDS[number], number>;
|
||||
for (const a of alarms) {
|
||||
base[a.kind] = (base[a.kind] ?? 0) + 1;
|
||||
}
|
||||
return base;
|
||||
}, [alarms]);
|
||||
|
||||
const enabledAlarmKinds = useMemo(() => {
|
||||
return LEGACY_ALARM_KINDS.filter((k) => alarmKindEnabled[k]);
|
||||
}, [alarmKindEnabled]);
|
||||
|
||||
const enabledAlarmKinds = useMemo(() => LEGACY_ALARM_KINDS.filter((k) => alarmKindEnabled[k]), [alarmKindEnabled]);
|
||||
const allAlarmKindsEnabled = enabledAlarmKinds.length === LEGACY_ALARM_KINDS.length;
|
||||
|
||||
const filteredAlarms = useMemo(() => {
|
||||
@ -254,13 +223,12 @@ export function DashboardPage() {
|
||||
[highlightedMmsiSet, availableTargetMmsiSet],
|
||||
);
|
||||
|
||||
const setUniqueSorted = (items: number[]) =>
|
||||
Array.from(new Set(items.filter((item) => Number.isFinite(item)))).sort((a, b) => a - b);
|
||||
const fishingCount = legacyVesselsAll.filter((v) => v.state.isFishing).length;
|
||||
const transitCount = legacyVesselsAll.filter((v) => v.state.isTransit).length;
|
||||
|
||||
const setSortedIfChanged = (next: number[]) => {
|
||||
const sorted = setUniqueSorted(next);
|
||||
return (prev: number[]) => (prev.length === sorted.length && prev.every((v, i) => v === sorted[i]) ? prev : sorted);
|
||||
};
|
||||
const enabledTypes = useMemo(() => VESSEL_TYPE_ORDER.filter((c) => typeEnabled[c]), [typeEnabled]);
|
||||
const speedPanelType = (selectedLegacyVessel?.shipCode ?? (enabledTypes.length === 1 ? enabledTypes[0] : "PT")) as VesselTypeCode;
|
||||
const alarmFilterSummary = allAlarmKindsEnabled ? "전체" : `${enabledAlarmKinds.length}/${LEGACY_ALARM_KINDS.length}`;
|
||||
|
||||
const handleFleetContextMenu = (ownerKey: string, mmsis: number[]) => {
|
||||
if (!mmsis.length) return;
|
||||
@ -275,414 +243,57 @@ export function DashboardPage() {
|
||||
const sumLon = members.reduce((acc, v) => acc + v.lon, 0);
|
||||
const sumLat = members.reduce((acc, v) => acc + v.lat, 0);
|
||||
const center: [number, number] = [sumLon / members.length, sumLat / members.length];
|
||||
setFleetFocus({
|
||||
id: `${ownerKey}-${Date.now()}`,
|
||||
center,
|
||||
zoom: 9,
|
||||
});
|
||||
setFleetFocus({ id: `${ownerKey}-${Date.now()}`, center, zoom: 9 });
|
||||
};
|
||||
|
||||
const toggleHighlightedMmsi = (mmsi: number) => {
|
||||
setHighlightedMmsiSet((prev) => {
|
||||
const next = new Set(prev);
|
||||
if (next.has(mmsi)) next.delete(mmsi);
|
||||
else next.add(mmsi);
|
||||
return Array.from(next).sort((a, b) => a - b);
|
||||
});
|
||||
};
|
||||
|
||||
const fishingCount = legacyVesselsAll.filter((v) => v.state.isFishing).length;
|
||||
const transitCount = legacyVesselsAll.filter((v) => v.state.isTransit).length;
|
||||
|
||||
const enabledTypes = useMemo(() => VESSEL_TYPE_ORDER.filter((c) => typeEnabled[c]), [typeEnabled]);
|
||||
const speedPanelType = (selectedLegacyVessel?.shipCode ?? (enabledTypes.length === 1 ? enabledTypes[0] : "PT")) as VesselTypeCode;
|
||||
const enabledAlarmKindCount = enabledAlarmKinds.length;
|
||||
const alarmFilterSummary = allAlarmKindsEnabled ? "전체" : `${enabledAlarmKindCount}/${LEGACY_ALARM_KINDS.length}`;
|
||||
|
||||
// ── Render ──
|
||||
return (
|
||||
<div className="app">
|
||||
<div className="grid h-screen grid-cols-[310px_1fr] grid-rows-[44px_1fr] max-md:grid-cols-[1fr] max-md:grid-rows-[auto_1fr]">
|
||||
<Topbar
|
||||
total={legacyVesselsAll.length}
|
||||
fishing={fishingCount}
|
||||
transit={transitCount}
|
||||
pairLinks={pairLinksAll.length}
|
||||
alarms={alarms.length}
|
||||
pollingStatus={snapshot.status}
|
||||
lastFetchMinutes={snapshot.lastFetchMinutes}
|
||||
clock={clock}
|
||||
adminMode={adminMode}
|
||||
onLogoClick={onLogoClick}
|
||||
userName={user?.name}
|
||||
onLogout={logout}
|
||||
theme={theme}
|
||||
onToggleTheme={toggleTheme}
|
||||
isSidebarOpen={isSidebarOpen}
|
||||
onMenuToggle={() => setIsSidebarOpen((v) => !v)}
|
||||
/>
|
||||
|
||||
<div className="sidebar">
|
||||
<div className="sb">
|
||||
<div className="sb-t">업종 필터</div>
|
||||
<div className="tog">
|
||||
<div
|
||||
className={`tog-btn ${showTargets ? "on" : ""}`}
|
||||
onClick={() => {
|
||||
setShowTargets((v) => {
|
||||
const next = !v;
|
||||
if (!next) setSelectedMmsi((m) => (legacyHits.has(m ?? -1) ? null : m));
|
||||
return next;
|
||||
});
|
||||
}}
|
||||
title="레거시(CN permit) 대상 선박 표시"
|
||||
>
|
||||
대상 선박
|
||||
</div>
|
||||
<div className={`tog-btn ${showOthers ? "on" : ""}`} onClick={() => setShowOthers((v) => !v)} title="대상 외 AIS 선박 표시">
|
||||
기타 AIS
|
||||
</div>
|
||||
</div>
|
||||
<TypeFilterGrid
|
||||
enabled={typeEnabled}
|
||||
totalCount={legacyVesselsAll.length}
|
||||
countsByType={legacyCounts}
|
||||
onToggle={(code) => {
|
||||
// When hiding the currently selected legacy vessel's type, clear selection.
|
||||
if (selectedLegacyVessel && selectedLegacyVessel.shipCode === code && typeEnabled[code]) setSelectedMmsi(null);
|
||||
setTypeEnabled((prev) => ({ ...prev, [code]: !prev[code] }));
|
||||
}}
|
||||
onToggleAll={() => {
|
||||
const allOn = VESSEL_TYPE_ORDER.every((c) => typeEnabled[c]);
|
||||
const nextVal = !allOn; // any-off -> true, all-on -> false
|
||||
if (!nextVal && selectedLegacyVessel) setSelectedMmsi(null);
|
||||
setTypeEnabled({
|
||||
PT: nextVal,
|
||||
"PT-S": nextVal,
|
||||
GN: nextVal,
|
||||
OT: nextVal,
|
||||
PS: nextVal,
|
||||
FC: nextVal,
|
||||
});
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
<DashboardSidebar
|
||||
isOpen={isSidebarOpen}
|
||||
onClose={() => setIsSidebarOpen(false)}
|
||||
state={state}
|
||||
legacyVesselsAll={legacyVesselsAll}
|
||||
legacyVesselsFiltered={legacyVesselsFiltered}
|
||||
legacyCounts={legacyCounts}
|
||||
selectedLegacyVessel={selectedLegacyVessel}
|
||||
activeHighlightedMmsiSet={activeHighlightedMmsiSet}
|
||||
legacyHits={legacyHits}
|
||||
filteredAlarms={filteredAlarms}
|
||||
alarms={alarms}
|
||||
alarmKindCounts={alarmKindCounts}
|
||||
allAlarmKindsEnabled={allAlarmKindsEnabled}
|
||||
alarmFilterSummary={alarmFilterSummary}
|
||||
speedPanelType={speedPanelType}
|
||||
onFleetContextMenu={handleFleetContextMenu}
|
||||
snapshot={snapshot}
|
||||
legacyError={legacyError}
|
||||
legacyData={legacyData}
|
||||
targetsInScope={targetsInScope}
|
||||
zonesError={zonesError}
|
||||
zones={zones}
|
||||
legacyIndex={legacyIndex}
|
||||
/>
|
||||
|
||||
<div className="sb">
|
||||
<div className="sb-t" style={{ display: "flex", alignItems: "center" }}>
|
||||
지도 표시 설정
|
||||
<div style={{ flex: 1 }} />
|
||||
<div
|
||||
className={`tog-btn ${projection === "globe" ? "on" : ""}`}
|
||||
onClick={() => setProjection((p) => (p === "globe" ? "mercator" : "globe"))}
|
||||
title="3D 지구본 투영: 드래그로 회전, 휠로 확대/축소"
|
||||
style={{ fontSize: 9, padding: "2px 8px" }}
|
||||
>
|
||||
3D
|
||||
</div>
|
||||
</div>
|
||||
<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>
|
||||
<div className={`tog-btn ${baseMap === "legacy" ? "on" : ""}`} onClick={() => setBaseMap("legacy")} title="레거시 대시보드(Carto Dark) 베이스맵">
|
||||
레거시
|
||||
</div>
|
||||
</div> */}
|
||||
</div>
|
||||
|
||||
<div className="sb">
|
||||
<div className="sb-t">속도 프로파일</div>
|
||||
<SpeedProfilePanel selectedType={speedPanelType} />
|
||||
</div>
|
||||
|
||||
<div className="sb" style={{ maxHeight: 260, display: "flex", flexDirection: "column", overflow: "hidden" }}>
|
||||
<div className="sb-t sb-t-row">
|
||||
<div>
|
||||
선단 연관관계{" "}
|
||||
<span style={{ color: "var(--accent)", fontSize: 8 }}>
|
||||
{selectedLegacyVessel ? `${selectedLegacyVessel.permitNo} 연관` : "(선박 클릭 시 표시)"}
|
||||
</span>
|
||||
</div>
|
||||
<div className="relation-sort">
|
||||
<label className="relation-sort__option">
|
||||
<input
|
||||
type="radio"
|
||||
name="fleet-relation-sort"
|
||||
checked={fleetRelationSortMode === "count"}
|
||||
onChange={() => setFleetRelationSortMode("count")}
|
||||
/>
|
||||
척수
|
||||
</label>
|
||||
<label className="relation-sort__option">
|
||||
<input
|
||||
type="radio"
|
||||
name="fleet-relation-sort"
|
||||
checked={fleetRelationSortMode === "range"}
|
||||
onChange={() => setFleetRelationSortMode("range")}
|
||||
/>
|
||||
범위
|
||||
</label>
|
||||
</div>
|
||||
</div>
|
||||
<div style={{ overflowY: "auto", minHeight: 0 }}>
|
||||
<RelationsPanel
|
||||
selectedVessel={selectedLegacyVessel}
|
||||
vessels={legacyVesselsAll}
|
||||
fleetVessels={legacyVesselsFiltered}
|
||||
onSelectMmsi={setSelectedMmsi}
|
||||
onToggleHighlightMmsi={toggleHighlightedMmsi}
|
||||
onHoverMmsi={(mmsis) => setHoveredMmsiSet(setUniqueSorted(mmsis))}
|
||||
onClearHover={() => setHoveredMmsiSet([])}
|
||||
onHoverPair={(pairMmsis) => setHoveredPairMmsiSet(setUniqueSorted(pairMmsis))}
|
||||
onClearPairHover={() => setHoveredPairMmsiSet([])}
|
||||
onHoverFleet={(ownerKey, fleetMmsis) => {
|
||||
setHoveredFleetOwnerKey(ownerKey);
|
||||
setHoveredFleetMmsiSet(setSortedIfChanged(fleetMmsis));
|
||||
}}
|
||||
onClearFleetHover={() => {
|
||||
setHoveredFleetOwnerKey(null);
|
||||
setHoveredFleetMmsiSet((prev) => (prev.length === 0 ? prev : []));
|
||||
}}
|
||||
fleetSortMode={fleetRelationSortMode}
|
||||
hoveredFleetOwnerKey={hoveredFleetOwnerKey}
|
||||
hoveredFleetMmsiSet={hoveredFleetMmsiSet}
|
||||
onContextMenuFleet={handleFleetContextMenu}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="sb" style={{ flex: 1, minHeight: 0, display: "flex", flexDirection: "column" }}>
|
||||
<div className="sb-t">
|
||||
선박 목록{" "}
|
||||
<span style={{ color: "var(--accent)", fontSize: 8 }}>
|
||||
({legacyVesselsFiltered.length}척)
|
||||
</span>
|
||||
</div>
|
||||
<VesselList
|
||||
vessels={legacyVesselsFiltered}
|
||||
selectedMmsi={selectedMmsi}
|
||||
highlightedMmsiSet={activeHighlightedMmsiSet}
|
||||
onSelectMmsi={setSelectedMmsi}
|
||||
onToggleHighlightMmsi={toggleHighlightedMmsi}
|
||||
onHoverMmsi={(mmsi) => setHoveredMmsiSet([mmsi])}
|
||||
onClearHover={() => setHoveredMmsiSet([])}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="sb" style={{ maxHeight: 130, display: "flex", flexDirection: "column", overflow: "visible" }}>
|
||||
<div className="sb-t sb-t-row" style={{ marginBottom: 6 }}>
|
||||
<div>
|
||||
실시간 경고{" "}
|
||||
<span style={{ color: "var(--accent)", fontSize: 8 }}>
|
||||
({filteredAlarms.length}/{alarms.length})
|
||||
</span>
|
||||
</div>
|
||||
|
||||
{LEGACY_ALARM_KINDS.length <= 3 ? (
|
||||
<div style={{ display: "flex", gap: 6, alignItems: "center" }}>
|
||||
{LEGACY_ALARM_KINDS.map((k) => (
|
||||
<label key={k} style={{ display: "inline-flex", gap: 4, alignItems: "center", cursor: "pointer", userSelect: "none" }}>
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={!!alarmKindEnabled[k]}
|
||||
onChange={() => setAlarmKindEnabled((prev) => ({ ...prev, [k]: !prev[k] }))}
|
||||
/>
|
||||
<span style={{ fontSize: 8, color: "var(--muted)", whiteSpace: "nowrap" }}>{LEGACY_ALARM_KIND_LABEL[k]}</span>
|
||||
</label>
|
||||
))}
|
||||
</div>
|
||||
) : (
|
||||
<details className="alarm-filter">
|
||||
<summary className="alarm-filter__summary" title="경고 종류 필터">
|
||||
{alarmFilterSummary}
|
||||
</summary>
|
||||
<div className="alarm-filter__menu" role="menu" aria-label="alarm kind filter">
|
||||
<label className="alarm-filter__row">
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={allAlarmKindsEnabled}
|
||||
onChange={() =>
|
||||
setAlarmKindEnabled((prev) => {
|
||||
const allOn = LEGACY_ALARM_KINDS.every((k) => prev[k]);
|
||||
const nextVal = allOn ? false : true;
|
||||
return Object.fromEntries(LEGACY_ALARM_KINDS.map((k) => [k, nextVal])) as Record<LegacyAlarmKind, boolean>;
|
||||
})
|
||||
}
|
||||
/>
|
||||
전체
|
||||
<span className="alarm-filter__cnt">{alarms.length}</span>
|
||||
</label>
|
||||
<div className="alarm-filter__sep" />
|
||||
{LEGACY_ALARM_KINDS.map((k) => (
|
||||
<label key={k} className="alarm-filter__row">
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={!!alarmKindEnabled[k]}
|
||||
onChange={() => setAlarmKindEnabled((prev) => ({ ...prev, [k]: !prev[k] }))}
|
||||
/>
|
||||
{LEGACY_ALARM_KIND_LABEL[k]}
|
||||
<span className="alarm-filter__cnt">{alarmKindCounts[k] ?? 0}</span>
|
||||
</label>
|
||||
))}
|
||||
</div>
|
||||
</details>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div style={{ overflowY: "auto", minHeight: 0, flex: 1 }}>
|
||||
<AlarmsPanel alarms={filteredAlarms} onSelectMmsi={setSelectedMmsi} />
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{adminMode ? (
|
||||
<>
|
||||
<div className="sb">
|
||||
<div className="sb-t">ADMIN · AIS Target Polling</div>
|
||||
<div style={{ fontSize: 11, lineHeight: 1.45 }}>
|
||||
<div style={{ color: "var(--muted)", fontSize: 10 }}>엔드포인트</div>
|
||||
<div style={{ wordBreak: "break-all" }}>{AIS_API_BASE}/api/ais-target/search</div>
|
||||
<div style={{ marginTop: 6, color: "var(--muted)", fontSize: 10 }}>상태</div>
|
||||
<div>
|
||||
<b style={{ color: snapshot.status === "ready" ? "#22C55E" : snapshot.status === "error" ? "#EF4444" : "#F59E0B" }}>
|
||||
{snapshot.status.toUpperCase()}
|
||||
</b>
|
||||
{snapshot.error ? <span style={{ marginLeft: 6, color: "#EF4444" }}>{snapshot.error}</span> : null}
|
||||
</div>
|
||||
<div style={{ marginTop: 6, color: "var(--muted)", fontSize: 10 }}>최근 fetch</div>
|
||||
<div>
|
||||
{fmtLocal(snapshot.lastFetchAt)}{" "}
|
||||
<span style={{ color: "var(--muted)", fontSize: 10 }}>
|
||||
({snapshot.lastFetchMinutes ?? "-"}m, inserted {snapshot.lastInserted}, upserted {snapshot.lastUpserted})
|
||||
</span>
|
||||
</div>
|
||||
<div style={{ marginTop: 6, color: "var(--muted)", fontSize: 10 }}>메시지</div>
|
||||
<div style={{ color: "var(--text)", fontSize: 10 }}>{snapshot.lastMessage ?? "-"}</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="sb">
|
||||
<div className="sb-t">ADMIN · Legacy (CN Permit)</div>
|
||||
{legacyError ? (
|
||||
<div style={{ fontSize: 11, color: "#EF4444" }}>legacy load error: {legacyError}</div>
|
||||
) : (
|
||||
<div style={{ fontSize: 11, lineHeight: 1.45 }}>
|
||||
<div style={{ color: "var(--muted)", fontSize: 10 }}>데이터셋</div>
|
||||
<div style={{ wordBreak: "break-all", fontSize: 10 }}>/data/legacy/chinese-permitted.v1.json</div>
|
||||
<div style={{ marginTop: 6, color: "var(--muted)", fontSize: 10 }}>매칭(현재 scope)</div>
|
||||
<div>
|
||||
<b style={{ color: "#F59E0B" }}>{legacyVesselsAll.length}</b>{" "}
|
||||
<span style={{ color: "var(--muted)", fontSize: 10 }}>/ {targetsInScope.length}</span>
|
||||
</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>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className="sb">
|
||||
<div className="sb-t">ADMIN · Viewport / BBox</div>
|
||||
<div style={{ fontSize: 11, lineHeight: 1.45 }}>
|
||||
<div style={{ color: "var(--muted)", fontSize: 10 }}>현재 View BBox</div>
|
||||
<div style={{ wordBreak: "break-all", fontSize: 10 }}>{fmtBbox(viewBbox)}</div>
|
||||
<div style={{ marginTop: 8, display: "flex", gap: 6, flexWrap: "wrap" }}>
|
||||
<button
|
||||
onClick={() => setUseViewportFilter((v) => !v)}
|
||||
style={{
|
||||
fontSize: 10,
|
||||
padding: "4px 8px",
|
||||
borderRadius: 6,
|
||||
border: "1px solid var(--border)",
|
||||
background: useViewportFilter ? "rgba(59,130,246,.18)" : "var(--card)",
|
||||
color: "var(--text)",
|
||||
cursor: "pointer",
|
||||
}}
|
||||
title="지도/리스트에 현재 화면 영역 내 선박만 표시(클라이언트 필터)"
|
||||
>
|
||||
Viewport filter {useViewportFilter ? "ON" : "OFF"}
|
||||
</button>
|
||||
|
||||
<button
|
||||
onClick={() => {
|
||||
if (!viewBbox) return;
|
||||
setUseApiBbox((v) => {
|
||||
const next = !v;
|
||||
if (next && viewBbox) setApiBbox(fmtBbox(viewBbox));
|
||||
if (!next) setApiBbox(undefined);
|
||||
return next;
|
||||
});
|
||||
}}
|
||||
style={{
|
||||
fontSize: 10,
|
||||
padding: "4px 8px",
|
||||
borderRadius: 6,
|
||||
border: "1px solid var(--border)",
|
||||
background: useApiBbox ? "rgba(245,158,11,.14)" : "var(--card)",
|
||||
color: viewBbox ? "var(--text)" : "var(--muted)",
|
||||
cursor: viewBbox ? "pointer" : "not-allowed",
|
||||
}}
|
||||
title="서버에서 bbox로 필터링해서 내려받기(페이로드 감소). 켜는 순간 현재 view bbox로 고정됨."
|
||||
disabled={!viewBbox}
|
||||
>
|
||||
API bbox {useApiBbox ? "ON" : "OFF"}
|
||||
</button>
|
||||
|
||||
<button
|
||||
onClick={() => {
|
||||
if (!viewBbox) return;
|
||||
setApiBbox(fmtBbox(viewBbox));
|
||||
setUseApiBbox(true);
|
||||
}}
|
||||
style={{
|
||||
fontSize: 10,
|
||||
padding: "4px 8px",
|
||||
borderRadius: 6,
|
||||
border: "1px solid var(--border)",
|
||||
background: "var(--card)",
|
||||
color: viewBbox ? "var(--text)" : "var(--muted)",
|
||||
cursor: viewBbox ? "pointer" : "not-allowed",
|
||||
}}
|
||||
disabled={!viewBbox}
|
||||
title="현재 view bbox로 API bbox를 갱신"
|
||||
>
|
||||
bbox=viewport
|
||||
</button>
|
||||
</div>
|
||||
<div style={{ marginTop: 8, color: "var(--muted)", fontSize: 10 }}>
|
||||
표시 선박: <b style={{ color: "var(--text)" }}>{targetsInScope.length}</b> / 스토어:{" "}
|
||||
<b style={{ color: "var(--text)" }}>{snapshot.total}</b>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="sb">
|
||||
<div className="sb-t">ADMIN · Map (Extras)</div>
|
||||
<Map3DSettingsToggles value={settings} onToggle={(k) => setSettings((prev) => ({ ...prev, [k]: !prev[k] }))} />
|
||||
<div style={{ fontSize: 10, color: "var(--muted)", marginTop: 6 }}>단일 WebGL 컨텍스트: MapboxOverlay(interleaved)</div>
|
||||
</div>
|
||||
|
||||
<div className="sb" style={{ flex: 1, minHeight: 0, display: "flex", flexDirection: "column" }}>
|
||||
<div className="sb-t">ADMIN · AIS Targets (All)</div>
|
||||
<AisTargetList
|
||||
targets={targetsInScope}
|
||||
selectedMmsi={selectedMmsi}
|
||||
onSelectMmsi={setSelectedMmsi}
|
||||
legacyIndex={legacyIndex}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="sb" style={{ maxHeight: 130, overflowY: "auto" }}>
|
||||
<div className="sb-t">ADMIN · 수역 데이터</div>
|
||||
{zonesError ? (
|
||||
<div style={{ fontSize: 11, color: "#EF4444" }}>zones load error: {zonesError}</div>
|
||||
) : (
|
||||
<div style={{ fontSize: 11, color: "var(--muted)" }}>
|
||||
{zones ? `loaded (${zones.features.length} features)` : "loading..."}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</>
|
||||
) : null}
|
||||
</div>
|
||||
|
||||
<div className="map-area">
|
||||
{isProjectionLoading ? (
|
||||
<div className="relative bg-[#010610]">
|
||||
{showMapLoader ? (
|
||||
<div className="map-loader-overlay" role="status" aria-live="polite">
|
||||
<div className="map-loader-overlay__panel">
|
||||
<div className="map-loader-overlay__spinner" />
|
||||
@ -714,7 +325,8 @@ export function DashboardPage() {
|
||||
fcLinks={fcLinksForMap}
|
||||
fleetCircles={fleetCirclesForMap}
|
||||
fleetFocus={fleetFocus}
|
||||
onProjectionLoadingChange={setIsProjectionLoading}
|
||||
onProjectionLoadingChange={handleProjectionLoadingChange}
|
||||
onGlobeShipsReady={setIsGlobeShipsReady}
|
||||
onHoverMmsi={(mmsis) => setHoveredMmsiSet(setUniqueSorted(mmsis))}
|
||||
onClearMmsiHover={() => setHoveredMmsiSet((prev) => (prev.length === 0 ? prev : []))}
|
||||
onHoverPair={(pairMmsis) => setHoveredPairMmsiSet(setUniqueSorted(pairMmsis))}
|
||||
@ -732,7 +344,23 @@ export function DashboardPage() {
|
||||
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 />
|
||||
|
||||
428
apps/web/src/pages/dashboard/DashboardSidebar.tsx
Normal file
428
apps/web/src/pages/dashboard/DashboardSidebar.tsx
Normal file
@ -0,0 +1,428 @@
|
||||
import { useEffect } from 'react';
|
||||
import { ToggleButton, Section } from '@wing/ui';
|
||||
import type { AisTarget } from '../../entities/aisTarget/model/types';
|
||||
import type { LegacyVesselIndex } from '../../entities/legacyVessel/lib';
|
||||
import type { LegacyVesselDataset, LegacyVesselInfo } from '../../entities/legacyVessel/model/types';
|
||||
import type { VesselTypeCode } from '../../entities/vessel/model/types';
|
||||
import { VESSEL_TYPE_ORDER } from '../../entities/vessel/model/meta';
|
||||
import type { ZonesGeoJson } from '../../entities/zone/api/useZones';
|
||||
import type { AisPollingSnapshot } from '../../features/aisPolling/useAisTargetPolling';
|
||||
import { Map3DSettingsToggles } from '../../features/map3dSettings/Map3DSettingsToggles';
|
||||
import type { DerivedLegacyVessel, LegacyAlarm, LegacyAlarmKind } from '../../features/legacyDashboard/model/types';
|
||||
import { LEGACY_ALARM_KIND_LABEL, LEGACY_ALARM_KINDS } from '../../features/legacyDashboard/model/types';
|
||||
import { MapToggles } from '../../features/mapToggles/MapToggles';
|
||||
import { TypeFilterGrid } from '../../features/typeFilter/TypeFilterGrid';
|
||||
import { AisTargetList } from '../../widgets/aisTargetList/AisTargetList';
|
||||
import { AlarmsPanel } from '../../widgets/alarms/AlarmsPanel';
|
||||
import { RelationsPanel } from '../../widgets/relations/RelationsPanel';
|
||||
import { SpeedProfilePanel } from '../../widgets/speed/SpeedProfilePanel';
|
||||
import { VesselList } from '../../widgets/vesselList/VesselList';
|
||||
import { fmtIsoFull } from '../../shared/lib/datetime';
|
||||
import type { useDashboardState } from './useDashboardState';
|
||||
import type { Bbox } from './useDashboardState';
|
||||
|
||||
const AIS_API_BASE = (import.meta.env.VITE_API_URL || '/snp-api').replace(/\/$/, '');
|
||||
|
||||
function fmtBbox(b: Bbox | null) {
|
||||
if (!b) return '-';
|
||||
return `${b[0].toFixed(4)},${b[1].toFixed(4)},${b[2].toFixed(4)},${b[3].toFixed(4)}`;
|
||||
}
|
||||
|
||||
interface DashboardSidebarProps {
|
||||
isOpen: boolean;
|
||||
onClose: () => void;
|
||||
state: ReturnType<typeof useDashboardState>;
|
||||
legacyVesselsAll: DerivedLegacyVessel[];
|
||||
legacyVesselsFiltered: DerivedLegacyVessel[];
|
||||
legacyCounts: Record<VesselTypeCode, number>;
|
||||
selectedLegacyVessel: DerivedLegacyVessel | null;
|
||||
activeHighlightedMmsiSet: number[];
|
||||
legacyHits: Map<number, LegacyVesselInfo>;
|
||||
filteredAlarms: LegacyAlarm[];
|
||||
alarms: LegacyAlarm[];
|
||||
alarmKindCounts: Record<LegacyAlarmKind, number>;
|
||||
allAlarmKindsEnabled: boolean;
|
||||
alarmFilterSummary: string;
|
||||
speedPanelType: VesselTypeCode;
|
||||
onFleetContextMenu: (ownerKey: string, mmsis: number[]) => void;
|
||||
snapshot: AisPollingSnapshot;
|
||||
legacyError: string | null;
|
||||
legacyData: LegacyVesselDataset | null;
|
||||
targetsInScope: AisTarget[];
|
||||
zonesError: string | null;
|
||||
zones: ZonesGeoJson | null;
|
||||
legacyIndex: LegacyVesselIndex | null;
|
||||
}
|
||||
|
||||
export function DashboardSidebar({
|
||||
isOpen,
|
||||
onClose,
|
||||
state,
|
||||
legacyVesselsAll,
|
||||
legacyVesselsFiltered,
|
||||
legacyCounts,
|
||||
selectedLegacyVessel,
|
||||
activeHighlightedMmsiSet,
|
||||
legacyHits,
|
||||
filteredAlarms,
|
||||
alarms,
|
||||
alarmKindCounts,
|
||||
allAlarmKindsEnabled,
|
||||
alarmFilterSummary,
|
||||
speedPanelType,
|
||||
onFleetContextMenu,
|
||||
snapshot,
|
||||
legacyError,
|
||||
legacyData,
|
||||
targetsInScope,
|
||||
zonesError,
|
||||
zones,
|
||||
legacyIndex,
|
||||
}: DashboardSidebarProps) {
|
||||
const {
|
||||
showTargets, setShowTargets, showOthers, setShowOthers,
|
||||
typeEnabled, setTypeEnabled,
|
||||
overlays, setOverlays,
|
||||
projection, setProjection, isProjectionToggleDisabled,
|
||||
selectedMmsi, setSelectedMmsi,
|
||||
fleetRelationSortMode, setFleetRelationSortMode,
|
||||
hoveredFleetOwnerKey, hoveredFleetMmsiSet,
|
||||
setHoveredMmsiSet, setHoveredPairMmsiSet,
|
||||
setHoveredFleetOwnerKey, setHoveredFleetMmsiSet,
|
||||
alarmKindEnabled, setAlarmKindEnabled,
|
||||
adminMode,
|
||||
viewBbox, useViewportFilter, setUseViewportFilter,
|
||||
useApiBbox, setUseApiBbox, setApiBbox,
|
||||
settings, setSettings,
|
||||
setUniqueSorted, setSortedIfChanged, toggleHighlightedMmsi,
|
||||
} = state;
|
||||
|
||||
useEffect(() => {
|
||||
if (!isOpen) return;
|
||||
const handler = (e: KeyboardEvent) => { if (e.key === 'Escape') onClose(); };
|
||||
document.addEventListener('keydown', handler);
|
||||
return () => document.removeEventListener('keydown', handler);
|
||||
}, [isOpen, onClose]);
|
||||
|
||||
return (
|
||||
<>
|
||||
{isOpen && (
|
||||
<div className="fixed inset-0 z-[1100] bg-black/50 md:hidden" onClick={onClose} aria-hidden />
|
||||
)}
|
||||
|
||||
<div
|
||||
className={`
|
||||
fixed inset-y-0 left-0 z-[1200] w-[310px] max-w-[100vw] transform overflow-y-auto
|
||||
border-r border-wing-border bg-wing-surface transition-transform duration-200
|
||||
${isOpen ? 'translate-x-0' : '-translate-x-full'}
|
||||
md:static md:z-auto md:translate-x-0 md:transition-none
|
||||
`}
|
||||
>
|
||||
<div className="h-[44px] md:hidden" />
|
||||
|
||||
<Section title="업종 필터">
|
||||
<div className="flex flex-wrap gap-0.75 mb-1.5">
|
||||
<ToggleButton
|
||||
on={showTargets}
|
||||
onClick={() => {
|
||||
setShowTargets((v) => {
|
||||
const next = !v;
|
||||
if (!next) setSelectedMmsi((m) => (legacyHits.has(m ?? -1) ? null : m));
|
||||
return next;
|
||||
});
|
||||
}}
|
||||
title="레거시(CN permit) 대상 선박 표시"
|
||||
>
|
||||
대상 선박
|
||||
</ToggleButton>
|
||||
<ToggleButton on={showOthers} onClick={() => setShowOthers((v) => !v)} title="대상 외 AIS 선박 표시">
|
||||
기타 AIS
|
||||
</ToggleButton>
|
||||
</div>
|
||||
<TypeFilterGrid
|
||||
enabled={typeEnabled}
|
||||
totalCount={legacyVesselsAll.length}
|
||||
countsByType={legacyCounts}
|
||||
onToggle={(code) => {
|
||||
if (selectedLegacyVessel && selectedLegacyVessel.shipCode === code && typeEnabled[code]) setSelectedMmsi(null);
|
||||
setTypeEnabled((prev) => ({ ...prev, [code]: !prev[code] }));
|
||||
}}
|
||||
onToggleAll={() => {
|
||||
const allOn = VESSEL_TYPE_ORDER.every((c) => typeEnabled[c]);
|
||||
const nextVal = !allOn;
|
||||
if (!nextVal && selectedLegacyVessel) setSelectedMmsi(null);
|
||||
setTypeEnabled({ PT: nextVal, 'PT-S': nextVal, GN: nextVal, OT: nextVal, PS: nextVal, FC: nextVal });
|
||||
}}
|
||||
/>
|
||||
</Section>
|
||||
|
||||
<Section
|
||||
title="지도 표시 설정"
|
||||
actions={
|
||||
<ToggleButton
|
||||
on={projection === 'globe'}
|
||||
onClick={isProjectionToggleDisabled ? undefined : () => setProjection((p) => (p === 'globe' ? 'mercator' : 'globe'))}
|
||||
title={isProjectionToggleDisabled ? '3D 모드 준비 중...' : '3D 지구본 투영'}
|
||||
className="px-2 py-0.5 text-[9px]"
|
||||
style={{ opacity: isProjectionToggleDisabled ? 0.4 : 1, cursor: isProjectionToggleDisabled ? 'not-allowed' : 'pointer' }}
|
||||
>
|
||||
3D
|
||||
</ToggleButton>
|
||||
}
|
||||
>
|
||||
<MapToggles value={overlays} onToggle={(k) => setOverlays((prev) => ({ ...prev, [k]: !prev[k] }))} />
|
||||
</Section>
|
||||
|
||||
<Section title="속도 프로파일" defaultOpen={false}>
|
||||
<SpeedProfilePanel selectedType={speedPanelType} />
|
||||
</Section>
|
||||
|
||||
<Section
|
||||
title={
|
||||
<>
|
||||
선단 연관관계{' '}
|
||||
<span className="text-[8px] font-normal normal-case tracking-normal text-wing-accent">
|
||||
{selectedLegacyVessel ? `${selectedLegacyVessel.permitNo} 연관` : '(선박 클릭 시 표시)'}
|
||||
</span>
|
||||
</>
|
||||
}
|
||||
actions={
|
||||
<div className="relation-sort">
|
||||
<label className="relation-sort__option">
|
||||
<input type="radio" name="fleet-relation-sort" checked={fleetRelationSortMode === 'count'} onChange={() => setFleetRelationSortMode('count')} />
|
||||
척수
|
||||
</label>
|
||||
<label className="relation-sort__option">
|
||||
<input type="radio" name="fleet-relation-sort" checked={fleetRelationSortMode === 'range'} onChange={() => setFleetRelationSortMode('range')} />
|
||||
범위
|
||||
</label>
|
||||
</div>
|
||||
}
|
||||
className="max-h-[260px] flex flex-col overflow-hidden [&>div:last-child]:overflow-y-auto [&>div:last-child]:min-h-0"
|
||||
>
|
||||
<RelationsPanel
|
||||
selectedVessel={selectedLegacyVessel}
|
||||
vessels={legacyVesselsAll}
|
||||
fleetVessels={legacyVesselsFiltered}
|
||||
onSelectMmsi={setSelectedMmsi}
|
||||
onToggleHighlightMmsi={toggleHighlightedMmsi}
|
||||
onHoverMmsi={(mmsis) => setHoveredMmsiSet(setUniqueSorted(mmsis))}
|
||||
onClearHover={() => setHoveredMmsiSet([])}
|
||||
onHoverPair={(pairMmsis) => setHoveredPairMmsiSet(setUniqueSorted(pairMmsis))}
|
||||
onClearPairHover={() => setHoveredPairMmsiSet([])}
|
||||
onHoverFleet={(ownerKey, fleetMmsis) => {
|
||||
setHoveredFleetOwnerKey(ownerKey);
|
||||
setHoveredFleetMmsiSet(setSortedIfChanged(fleetMmsis));
|
||||
}}
|
||||
onClearFleetHover={() => {
|
||||
setHoveredFleetOwnerKey(null);
|
||||
setHoveredFleetMmsiSet((prev) => (prev.length === 0 ? prev : []));
|
||||
}}
|
||||
fleetSortMode={fleetRelationSortMode}
|
||||
hoveredFleetOwnerKey={hoveredFleetOwnerKey}
|
||||
hoveredFleetMmsiSet={hoveredFleetMmsiSet}
|
||||
onContextMenuFleet={onFleetContextMenu}
|
||||
/>
|
||||
</Section>
|
||||
|
||||
<Section
|
||||
title={
|
||||
<>
|
||||
선박 목록{' '}
|
||||
<span className="text-[8px] font-normal normal-case tracking-normal text-wing-accent">({legacyVesselsFiltered.length}척)</span>
|
||||
</>
|
||||
}
|
||||
className="flex-1 min-h-0 flex flex-col [&>div:last-child]:overflow-y-auto [&>div:last-child]:min-h-0 [&>div:last-child]:flex-1"
|
||||
>
|
||||
<VesselList
|
||||
vessels={legacyVesselsFiltered}
|
||||
selectedMmsi={selectedMmsi}
|
||||
highlightedMmsiSet={activeHighlightedMmsiSet}
|
||||
onSelectMmsi={setSelectedMmsi}
|
||||
onToggleHighlightMmsi={toggleHighlightedMmsi}
|
||||
onHoverMmsi={(mmsi) => setHoveredMmsiSet([mmsi])}
|
||||
onClearHover={() => setHoveredMmsiSet([])}
|
||||
/>
|
||||
</Section>
|
||||
|
||||
<Section
|
||||
title={
|
||||
<>
|
||||
실시간 경고{' '}
|
||||
<span className="text-[8px] font-normal normal-case tracking-normal text-wing-accent">({filteredAlarms.length}/{alarms.length})</span>
|
||||
</>
|
||||
}
|
||||
actions={
|
||||
LEGACY_ALARM_KINDS.length <= 3 ? (
|
||||
<div className="flex gap-1.5 items-center">
|
||||
{LEGACY_ALARM_KINDS.map((k) => (
|
||||
<label key={k} className="inline-flex gap-1 items-center cursor-pointer select-none">
|
||||
<input type="checkbox" checked={!!alarmKindEnabled[k]} onChange={() => setAlarmKindEnabled((prev) => ({ ...prev, [k]: !prev[k] }))} />
|
||||
<span className="text-[8px] text-wing-muted whitespace-nowrap">{LEGACY_ALARM_KIND_LABEL[k]}</span>
|
||||
</label>
|
||||
))}
|
||||
</div>
|
||||
) : (
|
||||
<details className="alarm-filter">
|
||||
<summary className="alarm-filter__summary" title="경고 종류 필터">{alarmFilterSummary}</summary>
|
||||
<div className="alarm-filter__menu" role="menu" aria-label="alarm kind filter">
|
||||
<label className="alarm-filter__row">
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={allAlarmKindsEnabled}
|
||||
onChange={() =>
|
||||
setAlarmKindEnabled((prev) => {
|
||||
const allOn = LEGACY_ALARM_KINDS.every((k) => prev[k]);
|
||||
const nextVal = !allOn;
|
||||
return Object.fromEntries(LEGACY_ALARM_KINDS.map((k) => [k, nextVal])) as Record<LegacyAlarmKind, boolean>;
|
||||
})
|
||||
}
|
||||
/>
|
||||
전체
|
||||
<span className="alarm-filter__cnt">{alarms.length}</span>
|
||||
</label>
|
||||
<div className="alarm-filter__sep" />
|
||||
{LEGACY_ALARM_KINDS.map((k) => (
|
||||
<label key={k} className="alarm-filter__row">
|
||||
<input type="checkbox" checked={!!alarmKindEnabled[k]} onChange={() => setAlarmKindEnabled((prev) => ({ ...prev, [k]: !prev[k] }))} />
|
||||
{LEGACY_ALARM_KIND_LABEL[k]}
|
||||
<span className="alarm-filter__cnt">{alarmKindCounts[k] ?? 0}</span>
|
||||
</label>
|
||||
))}
|
||||
</div>
|
||||
</details>
|
||||
)
|
||||
}
|
||||
className="max-h-[130px] flex flex-col overflow-visible [&>div:last-child]:overflow-y-auto [&>div:last-child]:min-h-0 [&>div:last-child]:flex-1"
|
||||
>
|
||||
<AlarmsPanel alarms={filteredAlarms} onSelectMmsi={setSelectedMmsi} />
|
||||
</Section>
|
||||
|
||||
{adminMode ? (
|
||||
<>
|
||||
<Section title="ADMIN · AIS Target Polling" defaultOpen={false}>
|
||||
<div style={{ fontSize: 11, lineHeight: 1.45 }}>
|
||||
<div style={{ color: 'var(--muted)', fontSize: 10 }}>엔드포인트</div>
|
||||
<div style={{ wordBreak: 'break-all' }}>{AIS_API_BASE}/api/ais-target/search</div>
|
||||
<div style={{ marginTop: 6, color: 'var(--muted)', fontSize: 10 }}>상태</div>
|
||||
<div>
|
||||
<b style={{ color: snapshot.status === 'ready' ? '#22C55E' : snapshot.status === 'error' ? '#EF4444' : '#F59E0B' }}>
|
||||
{snapshot.status.toUpperCase()}
|
||||
</b>
|
||||
{snapshot.error ? <span style={{ marginLeft: 6, color: '#EF4444' }}>{snapshot.error}</span> : null}
|
||||
</div>
|
||||
<div style={{ marginTop: 6, color: 'var(--muted)', fontSize: 10 }}>최근 fetch</div>
|
||||
<div>
|
||||
{fmtIsoFull(snapshot.lastFetchAt)}{' '}
|
||||
<span style={{ color: 'var(--muted)', fontSize: 10 }}>
|
||||
({snapshot.lastFetchMinutes ?? '-'}m, inserted {snapshot.lastInserted}, upserted {snapshot.lastUpserted})
|
||||
</span>
|
||||
</div>
|
||||
<div style={{ marginTop: 6, color: 'var(--muted)', fontSize: 10 }}>메시지</div>
|
||||
<div style={{ color: 'var(--text)', fontSize: 10 }}>{snapshot.lastMessage ?? '-'}</div>
|
||||
</div>
|
||||
</Section>
|
||||
|
||||
<Section title="ADMIN · Legacy (CN Permit)" defaultOpen={false}>
|
||||
{legacyError ? (
|
||||
<div style={{ fontSize: 11, color: '#EF4444' }}>legacy load error: {legacyError}</div>
|
||||
) : (
|
||||
<div style={{ fontSize: 11, lineHeight: 1.45 }}>
|
||||
<div style={{ color: 'var(--muted)', fontSize: 10 }}>데이터셋</div>
|
||||
<div style={{ wordBreak: 'break-all', fontSize: 10 }}>/data/legacy/chinese-permitted.v1.json</div>
|
||||
<div style={{ marginTop: 6, color: 'var(--muted)', fontSize: 10 }}>매칭(현재 scope)</div>
|
||||
<div>
|
||||
<b style={{ color: '#F59E0B' }}>{legacyVesselsAll.length}</b>{' '}
|
||||
<span style={{ color: 'var(--muted)', fontSize: 10 }}>/ {targetsInScope.length}</span>
|
||||
</div>
|
||||
<div style={{ marginTop: 6, color: 'var(--muted)', fontSize: 10 }}>생성시각</div>
|
||||
<div style={{ fontSize: 10, color: 'var(--text)' }}>{legacyData?.generatedAt ? fmtIsoFull(legacyData.generatedAt) : 'loading...'}</div>
|
||||
</div>
|
||||
)}
|
||||
</Section>
|
||||
|
||||
<Section title="ADMIN · Viewport / BBox" defaultOpen={false}>
|
||||
<div style={{ fontSize: 11, lineHeight: 1.45 }}>
|
||||
<div style={{ color: 'var(--muted)', fontSize: 10 }}>현재 View BBox</div>
|
||||
<div style={{ wordBreak: 'break-all', fontSize: 10 }}>{fmtBbox(viewBbox)}</div>
|
||||
<div style={{ marginTop: 8, display: 'flex', gap: 6, flexWrap: 'wrap' }}>
|
||||
<button
|
||||
onClick={() => setUseViewportFilter((v) => !v)}
|
||||
style={{
|
||||
fontSize: 10, padding: '4px 8px', borderRadius: 6,
|
||||
border: '1px solid var(--border)',
|
||||
background: useViewportFilter ? 'rgba(59,130,246,.18)' : 'var(--card)',
|
||||
color: 'var(--text)', cursor: 'pointer',
|
||||
}}
|
||||
title="지도/리스트에 현재 화면 영역 내 선박만 표시(클라이언트 필터)"
|
||||
>
|
||||
Viewport filter {useViewportFilter ? 'ON' : 'OFF'}
|
||||
</button>
|
||||
<button
|
||||
onClick={() => {
|
||||
if (!viewBbox) return;
|
||||
setUseApiBbox((v) => {
|
||||
const next = !v;
|
||||
if (next && viewBbox) setApiBbox(fmtBbox(viewBbox));
|
||||
if (!next) setApiBbox(undefined);
|
||||
return next;
|
||||
});
|
||||
}}
|
||||
style={{
|
||||
fontSize: 10, padding: '4px 8px', borderRadius: 6,
|
||||
border: '1px solid var(--border)',
|
||||
background: useApiBbox ? 'rgba(245,158,11,.14)' : 'var(--card)',
|
||||
color: viewBbox ? 'var(--text)' : 'var(--muted)',
|
||||
cursor: viewBbox ? 'pointer' : 'not-allowed',
|
||||
}}
|
||||
title="서버에서 bbox로 필터링해서 내려받기"
|
||||
disabled={!viewBbox}
|
||||
>
|
||||
API bbox {useApiBbox ? 'ON' : 'OFF'}
|
||||
</button>
|
||||
<button
|
||||
onClick={() => { if (!viewBbox) return; setApiBbox(fmtBbox(viewBbox)); setUseApiBbox(true); }}
|
||||
style={{
|
||||
fontSize: 10, padding: '4px 8px', borderRadius: 6,
|
||||
border: '1px solid var(--border)', background: 'var(--card)',
|
||||
color: viewBbox ? 'var(--text)' : 'var(--muted)', cursor: viewBbox ? 'pointer' : 'not-allowed',
|
||||
}}
|
||||
disabled={!viewBbox}
|
||||
title="현재 view bbox로 API bbox를 갱신"
|
||||
>
|
||||
bbox=viewport
|
||||
</button>
|
||||
</div>
|
||||
<div style={{ marginTop: 8, color: 'var(--muted)', fontSize: 10 }}>
|
||||
표시 선박: <b style={{ color: 'var(--text)' }}>{targetsInScope.length}</b> / 스토어:{' '}
|
||||
<b style={{ color: 'var(--text)' }}>{snapshot.total}</b>
|
||||
</div>
|
||||
</div>
|
||||
</Section>
|
||||
|
||||
<Section title="ADMIN · Map (Extras)" defaultOpen={false}>
|
||||
<Map3DSettingsToggles value={settings} onToggle={(k) => setSettings((prev) => ({ ...prev, [k]: !prev[k] }))} />
|
||||
<div style={{ fontSize: 10, color: 'var(--muted)', marginTop: 6 }}>단일 WebGL 컨텍스트: MapboxOverlay(interleaved)</div>
|
||||
</Section>
|
||||
|
||||
<Section
|
||||
title="ADMIN · AIS Targets (All)"
|
||||
defaultOpen={false}
|
||||
className="flex-1 min-h-0 flex flex-col [&>div:last-child]:overflow-y-auto [&>div:last-child]:min-h-0 [&>div:last-child]:flex-1"
|
||||
>
|
||||
<AisTargetList targets={targetsInScope} selectedMmsi={selectedMmsi} onSelectMmsi={setSelectedMmsi} legacyIndex={legacyIndex} />
|
||||
</Section>
|
||||
|
||||
<Section title="ADMIN · 수역 데이터" defaultOpen={false} className="max-h-[130px] overflow-y-auto">
|
||||
{zonesError ? (
|
||||
<div style={{ fontSize: 11, color: '#EF4444' }}>zones load error: {zonesError}</div>
|
||||
) : (
|
||||
<div style={{ fontSize: 11, color: 'var(--muted)' }}>{zones ? `loaded (${zones.features.length} features)` : 'loading...'}</div>
|
||||
)}
|
||||
</Section>
|
||||
</>
|
||||
) : null}
|
||||
</div>
|
||||
</>
|
||||
);
|
||||
}
|
||||
147
apps/web/src/pages/dashboard/useDashboardState.ts
Normal file
147
apps/web/src/pages/dashboard/useDashboardState.ts
Normal file
@ -0,0 +1,147 @@
|
||||
import { useCallback, useEffect, useRef, useState } from 'react';
|
||||
import { usePersistedState } from '../../shared/hooks';
|
||||
import type { VesselTypeCode } from '../../entities/vessel/model/types';
|
||||
import type { MapToggleState } from '../../features/mapToggles/MapToggles';
|
||||
import type { LegacyAlarmKind } from '../../features/legacyDashboard/model/types';
|
||||
import { LEGACY_ALARM_KINDS } from '../../features/legacyDashboard/model/types';
|
||||
import type { BaseMapId, Map3DSettings, MapProjectionId } from '../../widgets/map3d/Map3D';
|
||||
import type { MapViewState } from '../../widgets/map3d/types';
|
||||
import { DEFAULT_MAP_STYLE_SETTINGS } from '../../features/mapSettings/types';
|
||||
import type { MapStyleSettings } from '../../features/mapSettings/types';
|
||||
import { fmtDateTimeFull } from '../../shared/lib/datetime';
|
||||
|
||||
export type Bbox = [number, number, number, number];
|
||||
export type FleetRelationSortMode = 'count' | 'range';
|
||||
|
||||
export function useDashboardState(uid: number | null) {
|
||||
// ── Map instance ──
|
||||
const [mapInstance, setMapInstance] = useState<import('maplibre-gl').Map | null>(null);
|
||||
const handleMapReady = useCallback((map: import('maplibre-gl').Map) => setMapInstance(map), []);
|
||||
|
||||
// ── Viewport / API BBox ──
|
||||
const [viewBbox, setViewBbox] = useState<Bbox | null>(null);
|
||||
const [useViewportFilter, setUseViewportFilter] = useState(false);
|
||||
const [useApiBbox, setUseApiBbox] = useState(false);
|
||||
const [apiBbox, setApiBbox] = useState<string | undefined>(undefined);
|
||||
|
||||
// ── Selection & hover ──
|
||||
const [selectedMmsi, setSelectedMmsi] = useState<number | null>(null);
|
||||
const [highlightedMmsiSet, setHighlightedMmsiSet] = useState<number[]>([]);
|
||||
const [hoveredMmsiSet, setHoveredMmsiSet] = useState<number[]>([]);
|
||||
const [hoveredFleetMmsiSet, setHoveredFleetMmsiSet] = useState<number[]>([]);
|
||||
const [hoveredPairMmsiSet, setHoveredPairMmsiSet] = useState<number[]>([]);
|
||||
const [hoveredFleetOwnerKey, setHoveredFleetOwnerKey] = useState<string | null>(null);
|
||||
|
||||
// ── Filters (persisted) ──
|
||||
const [typeEnabled, setTypeEnabled] = usePersistedState<Record<VesselTypeCode, boolean>>(
|
||||
uid, 'typeEnabled', { PT: true, 'PT-S': true, GN: true, OT: true, PS: true, FC: true },
|
||||
);
|
||||
const [showTargets, setShowTargets] = usePersistedState(uid, 'showTargets', true);
|
||||
const [showOthers, setShowOthers] = usePersistedState(uid, 'showOthers', false);
|
||||
|
||||
// ── Map settings (persisted) ──
|
||||
// 레거시 베이스맵 비활성 — 향후 위성/라이트 등 추가 시 재활용
|
||||
// eslint-disable-next-line @typescript-eslint/no-unused-vars
|
||||
const [baseMap, _setBaseMap] = useState<BaseMapId>('enhanced');
|
||||
const [projection, setProjection] = useState<MapProjectionId>('mercator');
|
||||
const [mapStyleSettings, setMapStyleSettings] = usePersistedState<MapStyleSettings>(uid, 'mapStyleSettings', DEFAULT_MAP_STYLE_SETTINGS);
|
||||
const [overlays, setOverlays] = usePersistedState<MapToggleState>(uid, 'overlays', {
|
||||
pairLines: true, pairRange: true, fcLines: true, zones: true,
|
||||
fleetCircles: true, predictVectors: true, shipLabels: true, subcables: false,
|
||||
});
|
||||
const [settings, setSettings] = usePersistedState<Map3DSettings>(uid, 'map3dSettings', {
|
||||
showShips: true, showDensity: false, showSeamark: false,
|
||||
});
|
||||
const [mapView, setMapView] = usePersistedState<MapViewState | null>(uid, 'mapView', null);
|
||||
|
||||
// ── Sort & alarm filters (persisted) ──
|
||||
const [fleetRelationSortMode, setFleetRelationSortMode] = usePersistedState<FleetRelationSortMode>(uid, 'sortMode', 'count');
|
||||
const [alarmKindEnabled, setAlarmKindEnabled] = usePersistedState<Record<LegacyAlarmKind, boolean>>(
|
||||
uid, 'alarmKindEnabled',
|
||||
() => Object.fromEntries(LEGACY_ALARM_KINDS.map((k) => [k, true])) as Record<LegacyAlarmKind, boolean>,
|
||||
);
|
||||
|
||||
// ── Fleet focus ──
|
||||
const [fleetFocus, setFleetFocus] = useState<{ id: string | number; center: [number, number]; zoom?: number } | undefined>(undefined);
|
||||
|
||||
// ── Cable ──
|
||||
const [hoveredCableId, setHoveredCableId] = useState<string | null>(null);
|
||||
const [selectedCableId, setSelectedCableId] = useState<string | null>(null);
|
||||
|
||||
// ── Track context menu ──
|
||||
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), []);
|
||||
|
||||
// ── Projection loading ──
|
||||
const [isProjectionLoading, setIsProjectionLoading] = useState(false);
|
||||
const [isGlobeShipsReady, setIsGlobeShipsReady] = useState(false);
|
||||
const handleProjectionLoadingChange = useCallback((loading: boolean) => setIsProjectionLoading(loading), []);
|
||||
const showMapLoader = isProjectionLoading;
|
||||
const isProjectionToggleDisabled = !isGlobeShipsReady || isProjectionLoading;
|
||||
|
||||
// ── Clock ──
|
||||
const [clock, setClock] = useState(() => fmtDateTimeFull(new Date()));
|
||||
useEffect(() => {
|
||||
const id = window.setInterval(() => setClock(fmtDateTimeFull(new Date())), 1000);
|
||||
return () => window.clearInterval(id);
|
||||
}, []);
|
||||
|
||||
// ── Admin mode (7 clicks within 900ms) ──
|
||||
const [adminMode, setAdminMode] = useState(false);
|
||||
const clicksRef = useRef<number[]>([]);
|
||||
const onLogoClick = () => {
|
||||
const now = Date.now();
|
||||
clicksRef.current = clicksRef.current.filter((t) => now - t < 900);
|
||||
clicksRef.current.push(now);
|
||||
if (clicksRef.current.length >= 7) {
|
||||
clicksRef.current = [];
|
||||
setAdminMode((v) => !v);
|
||||
}
|
||||
};
|
||||
|
||||
// ── Helpers ──
|
||||
const setUniqueSorted = (items: number[]) =>
|
||||
Array.from(new Set(items.filter((item) => Number.isFinite(item)))).sort((a, b) => a - b);
|
||||
|
||||
const setSortedIfChanged = (next: number[]) => {
|
||||
const sorted = setUniqueSorted(next);
|
||||
return (prev: number[]) => (prev.length === sorted.length && prev.every((v, i) => v === sorted[i]) ? prev : sorted);
|
||||
};
|
||||
|
||||
const toggleHighlightedMmsi = (mmsi: number) => {
|
||||
setHighlightedMmsiSet((prev) => {
|
||||
const s = new Set(prev);
|
||||
if (s.has(mmsi)) s.delete(mmsi);
|
||||
else s.add(mmsi);
|
||||
return Array.from(s).sort((a, b) => a - b);
|
||||
});
|
||||
};
|
||||
|
||||
return {
|
||||
mapInstance, handleMapReady,
|
||||
viewBbox, setViewBbox, useViewportFilter, setUseViewportFilter,
|
||||
useApiBbox, setUseApiBbox, apiBbox, setApiBbox,
|
||||
selectedMmsi, setSelectedMmsi,
|
||||
highlightedMmsiSet,
|
||||
hoveredMmsiSet, setHoveredMmsiSet,
|
||||
hoveredFleetMmsiSet, setHoveredFleetMmsiSet,
|
||||
hoveredPairMmsiSet, setHoveredPairMmsiSet,
|
||||
hoveredFleetOwnerKey, setHoveredFleetOwnerKey,
|
||||
typeEnabled, setTypeEnabled, showTargets, setShowTargets, showOthers, setShowOthers,
|
||||
baseMap, projection, setProjection,
|
||||
mapStyleSettings, setMapStyleSettings,
|
||||
overlays, setOverlays, settings, setSettings,
|
||||
mapView, setMapView,
|
||||
fleetRelationSortMode, setFleetRelationSortMode,
|
||||
alarmKindEnabled, setAlarmKindEnabled,
|
||||
fleetFocus, setFleetFocus,
|
||||
hoveredCableId, setHoveredCableId, selectedCableId, setSelectedCableId,
|
||||
trackContextMenu, handleOpenTrackMenu, handleCloseTrackMenu,
|
||||
handleProjectionLoadingChange,
|
||||
isGlobeShipsReady, setIsGlobeShipsReady,
|
||||
showMapLoader, isProjectionToggleDisabled,
|
||||
clock, adminMode, onLogoClick,
|
||||
setUniqueSorted, setSortedIfChanged, toggleHighlightedMmsi,
|
||||
};
|
||||
}
|
||||
2
apps/web/src/shared/hooks/index.ts
Normal file
2
apps/web/src/shared/hooks/index.ts
Normal file
@ -0,0 +1,2 @@
|
||||
export { usePersistedState } from './usePersistedState';
|
||||
export { useTheme } from './useTheme';
|
||||
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];
|
||||
}
|
||||
47
apps/web/src/shared/hooks/useTheme.ts
Normal file
47
apps/web/src/shared/hooks/useTheme.ts
Normal file
@ -0,0 +1,47 @@
|
||||
import { useState, useEffect, useCallback } from 'react';
|
||||
|
||||
type Theme = 'dark' | 'light';
|
||||
|
||||
const STORAGE_KEY = 'wing:theme';
|
||||
const DEFAULT_THEME: Theme = 'dark';
|
||||
|
||||
function readTheme(): Theme {
|
||||
try {
|
||||
const raw = localStorage.getItem(STORAGE_KEY);
|
||||
if (raw === 'light' || raw === 'dark') return raw;
|
||||
} catch {
|
||||
// storage unavailable
|
||||
}
|
||||
return DEFAULT_THEME;
|
||||
}
|
||||
|
||||
function applyTheme(theme: Theme) {
|
||||
document.documentElement.dataset.theme = theme;
|
||||
}
|
||||
|
||||
export function useTheme() {
|
||||
const [theme, setThemeState] = useState<Theme>(() => {
|
||||
const t = readTheme();
|
||||
applyTheme(t);
|
||||
return t;
|
||||
});
|
||||
|
||||
useEffect(() => {
|
||||
applyTheme(theme);
|
||||
}, [theme]);
|
||||
|
||||
const setTheme = useCallback((t: Theme) => {
|
||||
setThemeState(t);
|
||||
try {
|
||||
localStorage.setItem(STORAGE_KEY, t);
|
||||
} catch {
|
||||
// quota exceeded or unavailable
|
||||
}
|
||||
}, []);
|
||||
|
||||
const toggleTheme = useCallback(() => {
|
||||
setTheme(theme === 'dark' ? 'light' : 'dark');
|
||||
}, [theme, setTheme]);
|
||||
|
||||
return { theme, setTheme, toggleTheme } as const;
|
||||
}
|
||||
@ -1,7 +0,0 @@
|
||||
export function hexToRgb(hex: string): [number, number, number] {
|
||||
const m = /^#?([0-9a-fA-F]{6})$/.exec(hex.trim());
|
||||
if (!m) return [255, 255, 255];
|
||||
const n = parseInt(m[1], 16);
|
||||
return [(n >> 16) & 255, (n >> 8) & 255, n & 255];
|
||||
}
|
||||
|
||||
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 });
|
||||
}
|
||||
20
apps/web/src/shared/lib/map/mapConstants.ts
Normal file
20
apps/web/src/shared/lib/map/mapConstants.ts
Normal file
@ -0,0 +1,20 @@
|
||||
// ── Shared map constants ──
|
||||
// Moved from widgets/map3d/constants.ts to resolve FSD layer violation
|
||||
// (features/ must not import from widgets/).
|
||||
|
||||
export const SHIP_ICON_MAPPING = {
|
||||
ship: {
|
||||
x: 0,
|
||||
y: 0,
|
||||
width: 128,
|
||||
height: 128,
|
||||
anchorX: 64,
|
||||
anchorY: 64,
|
||||
mask: true,
|
||||
},
|
||||
} as const;
|
||||
|
||||
export const DEPTH_DISABLED_PARAMS = {
|
||||
depthCompare: 'always',
|
||||
depthWriteEnabled: false,
|
||||
} as const;
|
||||
9
apps/web/src/shared/lib/map/mapTilerKey.ts
Normal file
9
apps/web/src/shared/lib/map/mapTilerKey.ts
Normal file
@ -0,0 +1,9 @@
|
||||
// Moved from widgets/map3d/lib/mapCore.ts to resolve FSD layer violation
|
||||
// (features/ must not import from widgets/).
|
||||
|
||||
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;
|
||||
}
|
||||
@ -1,5 +1,6 @@
|
||||
import type { AisTarget } from "../../entities/aisTarget/model/types";
|
||||
import type { LegacyVesselInfo } from "../../entities/legacyVessel/model/types";
|
||||
import { fmtIsoFull } from "../../shared/lib/datetime";
|
||||
|
||||
type Props = {
|
||||
target: AisTarget;
|
||||
@ -85,11 +86,11 @@ export function AisInfoPanel({ target: t, legacy, onClose }: Props) {
|
||||
</div>
|
||||
<div className="ir">
|
||||
<span className="il">Msg TS</span>
|
||||
<span className="iv">{t.messageTimestamp || "-"}</span>
|
||||
<span className="iv">{fmtIsoFull(t.messageTimestamp)}</span>
|
||||
</div>
|
||||
<div className="ir">
|
||||
<span className="il">Received</span>
|
||||
<span className="iv">{t.receivedDate || "-"}</span>
|
||||
<span className="iv">{fmtIsoFull(t.receivedDate)}</span>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
|
||||
@ -2,6 +2,7 @@ import { useMemo, useState } from "react";
|
||||
import type { AisTarget } from "../../entities/aisTarget/model/types";
|
||||
import type { LegacyVesselIndex } from "../../entities/legacyVessel/lib";
|
||||
import { matchLegacyVessel } from "../../entities/legacyVessel/lib";
|
||||
import { fmtIsoTime } from "../../shared/lib/datetime";
|
||||
|
||||
type SortMode = "recent" | "speed";
|
||||
|
||||
@ -23,13 +24,6 @@ function getSpeedColor(sog: unknown) {
|
||||
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) {
|
||||
const [q, setQ] = useState("");
|
||||
const [mode, setMode] = useState<SortMode>("recent");
|
||||
@ -96,7 +90,7 @@ export function AisTargetList({ targets, selectedMmsi, onSelectMmsi, legacyIndex
|
||||
const sel = selectedMmsi && t.mmsi === selectedMmsi;
|
||||
const sp = isFiniteNumber(t.sog) ? t.sog.toFixed(1) : "?";
|
||||
const sc = getSpeedColor(t.sog);
|
||||
const ts = fmtLocalTime(t.messageTimestamp);
|
||||
const ts = fmtIsoTime(t.messageTimestamp);
|
||||
const legacy = legacyIndex ? matchLegacyVessel(t, legacyIndex) : null;
|
||||
const legacyCode = legacy?.shipCode || "";
|
||||
|
||||
|
||||
@ -1,6 +1,7 @@
|
||||
import { ZONE_META } from "../../entities/zone/model/meta";
|
||||
import { SPEED_MAX, VESSEL_TYPES } from "../../entities/vessel/model/meta";
|
||||
import type { DerivedLegacyVessel } from "../../features/legacyDashboard/model/types";
|
||||
import { fmtIsoFull } from "../../shared/lib/datetime";
|
||||
import { haversineNm } from "../../shared/lib/geo/haversineNm";
|
||||
import { OVERLAY_RGB, rgbToHex } from "../../shared/lib/map/palette";
|
||||
|
||||
@ -75,7 +76,7 @@ export function VesselInfoPanel({ vessel: v, allVessels, onClose, onSelectMmsi }
|
||||
</div>
|
||||
<div className="ir">
|
||||
<span className="il">Msg TS</span>
|
||||
<span className="iv">{v.messageTimestamp || "-"}</span>
|
||||
<span className="iv">{fmtIsoFull(v.messageTimestamp)}</span>
|
||||
</div>
|
||||
<div className="ir">
|
||||
<span className="il">소유주</span>
|
||||
|
||||
@ -1,114 +1,121 @@
|
||||
import { useState } from 'react';
|
||||
import { ZONE_IDS, ZONE_META } from "../../entities/zone/model/meta";
|
||||
import { LEGACY_CODE_COLORS_HEX, OTHER_AIS_SPEED_HEX, OVERLAY_RGB, rgbToHex, rgba } from "../../shared/lib/map/palette";
|
||||
|
||||
export function MapLegend() {
|
||||
const [isOpen, setIsOpen] = useState(true);
|
||||
|
||||
return (
|
||||
<div className="map-legend">
|
||||
<div className="lt">수역</div>
|
||||
{ZONE_IDS.map((z) => (
|
||||
<div key={z} className="li">
|
||||
<div className="ls" style={{ background: `${ZONE_META[z].color}33`, border: `1px solid ${ZONE_META[z].color}` }} />
|
||||
{ZONE_META[z].name}
|
||||
</div>
|
||||
))}
|
||||
<button
|
||||
className="flex w-full cursor-pointer items-center justify-between border-none bg-transparent p-0 text-left"
|
||||
onClick={() => setIsOpen((v) => !v)}
|
||||
>
|
||||
<span className="lt" style={{ marginBottom: 0 }}>범례</span>
|
||||
<span className="text-[9px] text-wing-muted">{isOpen ? '▾' : '▸'}</span>
|
||||
</button>
|
||||
|
||||
<div className="lt" style={{ marginTop: 8 }}>
|
||||
기타 AIS 선박(속도)
|
||||
</div>
|
||||
<div className="li">
|
||||
<div className="ls" style={{ background: OTHER_AIS_SPEED_HEX.fast, borderRadius: 999 }} />
|
||||
SOG ≥ 10 kt
|
||||
</div>
|
||||
<div className="li">
|
||||
<div className="ls" style={{ background: OTHER_AIS_SPEED_HEX.moving, borderRadius: 999 }} />
|
||||
1 ≤ SOG < 10 kt
|
||||
</div>
|
||||
<div className="li">
|
||||
<div className="ls" style={{ background: OTHER_AIS_SPEED_HEX.stopped, borderRadius: 999 }} />
|
||||
SOG < 1 kt
|
||||
</div>
|
||||
<div className="li">
|
||||
<div className="ls" style={{ background: OTHER_AIS_SPEED_HEX.moving, opacity: 0.55, borderRadius: 999 }} />
|
||||
SOG unknown
|
||||
</div>
|
||||
{isOpen && (
|
||||
<>
|
||||
<div className="lt" style={{ marginTop: 6 }}>수역</div>
|
||||
{ZONE_IDS.map((z) => (
|
||||
<div key={z} className="li">
|
||||
<div className="ls" style={{ background: `${ZONE_META[z].color}33`, border: `1px solid ${ZONE_META[z].color}` }} />
|
||||
{ZONE_META[z].name}
|
||||
</div>
|
||||
))}
|
||||
|
||||
<div className="lt" style={{ marginTop: 8 }}>
|
||||
CN Permit(업종)
|
||||
</div>
|
||||
<div className="li">
|
||||
<div className="ls" style={{ background: LEGACY_CODE_COLORS_HEX.PT, borderRadius: 999 }} />
|
||||
PT 본선 (ring + 색상)
|
||||
</div>
|
||||
<div className="li">
|
||||
<div className="ls" style={{ background: LEGACY_CODE_COLORS_HEX["PT-S"], borderRadius: 999 }} />
|
||||
PT-S 부속선
|
||||
</div>
|
||||
<div className="li">
|
||||
<div className="ls" style={{ background: LEGACY_CODE_COLORS_HEX.GN, borderRadius: 999 }} />
|
||||
GN 유망
|
||||
</div>
|
||||
<div className="li">
|
||||
<div className="ls" style={{ background: LEGACY_CODE_COLORS_HEX.OT, borderRadius: 999 }} />
|
||||
OT 1척식
|
||||
</div>
|
||||
<div className="li">
|
||||
<div className="ls" style={{ background: LEGACY_CODE_COLORS_HEX.PS, borderRadius: 999 }} />
|
||||
PS 위망
|
||||
</div>
|
||||
<div className="li">
|
||||
<div className="ls" style={{ background: LEGACY_CODE_COLORS_HEX.FC, borderRadius: 999 }} />
|
||||
FC 운반선
|
||||
</div>
|
||||
<div className="li">
|
||||
<div className="ls" style={{ background: LEGACY_CODE_COLORS_HEX.C21, borderRadius: 999 }} />
|
||||
C21
|
||||
</div>
|
||||
<div className="lt" style={{ marginTop: 8 }}>기타 AIS 선박(속도)</div>
|
||||
<div className="li">
|
||||
<div className="ls" style={{ background: OTHER_AIS_SPEED_HEX.fast, borderRadius: 999 }} />
|
||||
SOG ≥ 10 kt
|
||||
</div>
|
||||
<div className="li">
|
||||
<div className="ls" style={{ background: OTHER_AIS_SPEED_HEX.moving, borderRadius: 999 }} />
|
||||
1 ≤ SOG < 10 kt
|
||||
</div>
|
||||
<div className="li">
|
||||
<div className="ls" style={{ background: OTHER_AIS_SPEED_HEX.stopped, borderRadius: 999 }} />
|
||||
SOG < 1 kt
|
||||
</div>
|
||||
<div className="li">
|
||||
<div className="ls" style={{ background: OTHER_AIS_SPEED_HEX.moving, opacity: 0.55, borderRadius: 999 }} />
|
||||
SOG unknown
|
||||
</div>
|
||||
|
||||
<div className="lt" style={{ marginTop: 8 }}>
|
||||
밀도(3D)
|
||||
</div>
|
||||
<div className="li" style={{ color: "var(--muted)" }}>
|
||||
Hexagon: 화면 내 AIS 포인트 집계
|
||||
</div>
|
||||
<div className="lt" style={{ marginTop: 8 }}>CN Permit(업종)</div>
|
||||
<div className="li">
|
||||
<div className="ls" style={{ background: LEGACY_CODE_COLORS_HEX.PT, borderRadius: 999 }} />
|
||||
PT 본선
|
||||
</div>
|
||||
<div className="li">
|
||||
<div className="ls" style={{ background: LEGACY_CODE_COLORS_HEX["PT-S"], borderRadius: 999 }} />
|
||||
PT-S 부속선
|
||||
</div>
|
||||
<div className="li">
|
||||
<div className="ls" style={{ background: LEGACY_CODE_COLORS_HEX.GN, borderRadius: 999 }} />
|
||||
GN 유망
|
||||
</div>
|
||||
<div className="li">
|
||||
<div className="ls" style={{ background: LEGACY_CODE_COLORS_HEX.OT, borderRadius: 999 }} />
|
||||
OT 1척식
|
||||
</div>
|
||||
<div className="li">
|
||||
<div className="ls" style={{ background: LEGACY_CODE_COLORS_HEX.PS, borderRadius: 999 }} />
|
||||
PS 위망
|
||||
</div>
|
||||
<div className="li">
|
||||
<div className="ls" style={{ background: LEGACY_CODE_COLORS_HEX.FC, borderRadius: 999 }} />
|
||||
FC 운반선
|
||||
</div>
|
||||
<div className="li">
|
||||
<div className="ls" style={{ background: LEGACY_CODE_COLORS_HEX.C21, borderRadius: 999 }} />
|
||||
C21
|
||||
</div>
|
||||
|
||||
<div className="lt" style={{ marginTop: 8 }}>
|
||||
연결선
|
||||
</div>
|
||||
<div className="li">
|
||||
<div style={{ width: 20, height: 2, background: rgba(OVERLAY_RGB.pairNormal, 0.35), borderRadius: 1 }} />
|
||||
PT↔PT-S 쌍 (정상)
|
||||
</div>
|
||||
<div className="li">
|
||||
<div style={{ width: 14, height: 14, borderRadius: "50%", border: `1px solid ${rgba(OVERLAY_RGB.pairNormal, 0.6)}` }} />
|
||||
쌍 연결범위
|
||||
</div>
|
||||
<div className="li">
|
||||
<div style={{ width: 20, height: 2, background: rgbToHex(OVERLAY_RGB.pairWarn), borderRadius: 1 }} />
|
||||
쌍 이격 경고 (>3NM)
|
||||
</div>
|
||||
<div className="li">
|
||||
<div style={{ width: 20, height: 2, background: rgbToHex(OVERLAY_RGB.fcTransfer), borderRadius: 1 }} />
|
||||
FC 환적 연결 (dashed)
|
||||
</div>
|
||||
<div className="li">
|
||||
<div style={{ width: 14, height: 14, borderRadius: "50%", border: `1px solid ${rgba(OVERLAY_RGB.fleetRange, 0.75)}`, opacity: 0.8 }} />
|
||||
선단 범위
|
||||
</div>
|
||||
<div className="li">
|
||||
<div style={{ width: 20, height: 2, background: rgbToHex(OVERLAY_RGB.suspicious), borderRadius: 1 }} />
|
||||
FC 환적 연결 (의심)
|
||||
</div>
|
||||
<div className="li">
|
||||
<div
|
||||
style={{
|
||||
width: 20,
|
||||
height: 2,
|
||||
borderRadius: 1,
|
||||
background: "repeating-linear-gradient(to right, rgba(226,232,240,0.55), rgba(226,232,240,0.55) 4px, rgba(0,0,0,0) 4px, rgba(0,0,0,0) 7px)",
|
||||
}}
|
||||
/>
|
||||
예측 벡터 (15분)
|
||||
</div>
|
||||
<div className="lt" style={{ marginTop: 8 }}>밀도(3D)</div>
|
||||
<div className="li" style={{ color: "var(--muted)" }}>
|
||||
Hexagon: 화면 내 AIS 포인트 집계
|
||||
</div>
|
||||
|
||||
<div className="lt" style={{ marginTop: 8 }}>연결선</div>
|
||||
<div className="li">
|
||||
<div style={{ width: 20, height: 2, background: rgba(OVERLAY_RGB.pairNormal, 0.35), borderRadius: 1 }} />
|
||||
PT↔PT-S 쌍 (정상)
|
||||
</div>
|
||||
<div className="li">
|
||||
<div style={{ width: 14, height: 14, borderRadius: "50%", border: `1px solid ${rgba(OVERLAY_RGB.pairNormal, 0.6)}` }} />
|
||||
쌍 연결범위
|
||||
</div>
|
||||
<div className="li">
|
||||
<div style={{ width: 20, height: 2, background: rgbToHex(OVERLAY_RGB.pairWarn), borderRadius: 1 }} />
|
||||
쌍 이격 경고 (>3NM)
|
||||
</div>
|
||||
<div className="li">
|
||||
<div style={{ width: 20, height: 2, background: rgbToHex(OVERLAY_RGB.fcTransfer), borderRadius: 1 }} />
|
||||
FC 환적 연결 (dashed)
|
||||
</div>
|
||||
<div className="li">
|
||||
<div style={{ width: 14, height: 14, borderRadius: "50%", border: `1px solid ${rgba(OVERLAY_RGB.fleetRange, 0.75)}`, opacity: 0.8 }} />
|
||||
선단 범위
|
||||
</div>
|
||||
<div className="li">
|
||||
<div style={{ width: 20, height: 2, background: rgbToHex(OVERLAY_RGB.suspicious), borderRadius: 1 }} />
|
||||
FC 환적 연결 (의심)
|
||||
</div>
|
||||
<div className="li">
|
||||
<div
|
||||
style={{
|
||||
width: 20,
|
||||
height: 2,
|
||||
borderRadius: 1,
|
||||
background: "repeating-linear-gradient(to right, rgba(226,232,240,0.55), rgba(226,232,240,0.55) 4px, rgba(0,0,0,0) 4px, rgba(0,0,0,0) 7px)",
|
||||
}}
|
||||
/>
|
||||
예측 벡터 (15분)
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
@ -26,7 +26,13 @@ import { useGlobeOverlays } from './hooks/useGlobeOverlays';
|
||||
import { useGlobeInteraction } from './hooks/useGlobeInteraction';
|
||||
import { useDeckLayers } from './hooks/useDeckLayers';
|
||||
import { useSubcablesLayer } from './hooks/useSubcablesLayer';
|
||||
import { useTrackReplayLayer } from './hooks/useTrackReplayLayer';
|
||||
import { useMapStyleSettings } from './hooks/useMapStyleSettings';
|
||||
import { VesselContextMenu } from './components/VesselContextMenu';
|
||||
import { useLiveShipAdapter } from '../../features/liveRenderer/hooks/useLiveShipAdapter';
|
||||
import { useLiveShipBatchRender } from '../../features/liveRenderer/hooks/useLiveShipBatchRender';
|
||||
import { useTrackQueryStore } from '../../features/trackReplay/stores/trackQueryStore';
|
||||
import { useTrackReplayDeckLayers } from '../../features/trackReplay/hooks/useTrackReplayDeckLayers';
|
||||
|
||||
export type { Map3DSettings, BaseMapId, MapProjectionId } from './types';
|
||||
|
||||
@ -66,14 +72,16 @@ export function Map3D({
|
||||
onHoverCable,
|
||||
onClickCable,
|
||||
mapStyleSettings,
|
||||
initialView,
|
||||
onViewStateChange,
|
||||
onGlobeShipsReady,
|
||||
activeTrack = null,
|
||||
trackContextMenu = null,
|
||||
onRequestTrack,
|
||||
onCloseTrackMenu,
|
||||
onOpenTrackMenu,
|
||||
onMapReady,
|
||||
}: Props) {
|
||||
void onHoverFleet;
|
||||
void onClearFleetHover;
|
||||
void onHoverMmsi;
|
||||
void onClearMmsiHover;
|
||||
void onHoverPair;
|
||||
void onClearPairHover;
|
||||
|
||||
// ── Shared refs ──────────────────────────────────────────────────────
|
||||
const containerRef = useRef<HTMLDivElement | null>(null);
|
||||
const mapRef = useRef<maplibregl.Map | null>(null);
|
||||
@ -91,6 +99,7 @@ export function Map3D({
|
||||
|
||||
// ── Hover state ──────────────────────────────────────────────────────
|
||||
const {
|
||||
hoveredDeckMmsiSet: hoveredDeckMmsiArr,
|
||||
setHoveredDeckMmsiSet,
|
||||
setHoveredDeckPairMmsiSet,
|
||||
setHoveredDeckFleetOwnerKey,
|
||||
@ -189,20 +198,38 @@ export function Map3D({
|
||||
);
|
||||
|
||||
// ── Ship data memos ──────────────────────────────────────────────────
|
||||
const shipData = useMemo(() => {
|
||||
const rawShipData = useMemo(() => {
|
||||
return targets.filter((t) => isFiniteNumber(t.lat) && isFiniteNumber(t.lon) && isFiniteNumber(t.mmsi));
|
||||
}, [targets]);
|
||||
|
||||
const hideLiveShips = useTrackQueryStore((state) => state.hideLiveShips);
|
||||
|
||||
const liveShipFeatures = useLiveShipAdapter(rawShipData, legacyHits ?? null);
|
||||
const { renderedTargets: batchRenderedTargets } = useLiveShipBatchRender(
|
||||
mapRef,
|
||||
liveShipFeatures,
|
||||
rawShipData,
|
||||
mapSyncEpoch,
|
||||
);
|
||||
|
||||
const shipData = useMemo(
|
||||
() => (hideLiveShips ? [] : rawShipData),
|
||||
[hideLiveShips, rawShipData],
|
||||
);
|
||||
|
||||
const shipByMmsi = useMemo(() => {
|
||||
const byMmsi = new Map<number, AisTarget>();
|
||||
for (const t of shipData) byMmsi.set(t.mmsi, t);
|
||||
for (const t of rawShipData) byMmsi.set(t.mmsi, t);
|
||||
return byMmsi;
|
||||
}, [shipData]);
|
||||
}, [rawShipData]);
|
||||
|
||||
const shipLayerData = useMemo(() => {
|
||||
if (shipData.length === 0) return shipData;
|
||||
return [...shipData];
|
||||
}, [shipData]);
|
||||
if (hideLiveShips) return [];
|
||||
// Fallback to raw targets when batch result is temporarily empty
|
||||
// (e.g. overlay update race or viewport sync delay).
|
||||
if (batchRenderedTargets.length === 0) return rawShipData;
|
||||
return [...batchRenderedTargets];
|
||||
}, [hideLiveShips, batchRenderedTargets, rawShipData]);
|
||||
|
||||
const shipHighlightSet = useMemo(() => {
|
||||
const out = new Set(highlightedMmsiSetForShips);
|
||||
@ -224,6 +251,8 @@ export function Map3D({
|
||||
return shipLayerData.filter((target) => shipHighlightSet.has(target.mmsi));
|
||||
}, [shipHighlightSet, shipLayerData]);
|
||||
|
||||
const trackReplayRenderState = useTrackReplayDeckLayers();
|
||||
|
||||
// ── Deck hover management ────────────────────────────────────────────
|
||||
const hasAuxiliarySelectModifier = useCallback(
|
||||
(ev?: { shiftKey?: boolean; ctrlKey?: boolean; metaKey?: boolean } | null): boolean => {
|
||||
@ -283,22 +312,51 @@ export function Map3D({
|
||||
ownerKey: null,
|
||||
vesselMmsis: [],
|
||||
});
|
||||
const mapDrivenMmsiHoverRef = useRef(false);
|
||||
const mapDrivenPairHoverRef = useRef(false);
|
||||
const mapDrivenFleetHoverRef = useRef(false);
|
||||
|
||||
const clearMapFleetHoverState = useCallback(() => {
|
||||
const prev = mapFleetHoverStateRef.current;
|
||||
mapFleetHoverStateRef.current = { ownerKey: null, vesselMmsis: [] };
|
||||
setHoveredDeckFleetOwner(null);
|
||||
setHoveredDeckFleetMmsis([]);
|
||||
}, [setHoveredDeckFleetOwner, setHoveredDeckFleetMmsis]);
|
||||
if (
|
||||
mapDrivenFleetHoverRef.current &&
|
||||
(prev.ownerKey != null || prev.vesselMmsis.length > 0) &&
|
||||
hoveredFleetOwnerKey === prev.ownerKey &&
|
||||
equalNumberArrays(hoveredFleetMmsiSet, prev.vesselMmsis)
|
||||
) {
|
||||
onClearFleetHover?.();
|
||||
}
|
||||
mapDrivenFleetHoverRef.current = false;
|
||||
}, [
|
||||
setHoveredDeckFleetOwner,
|
||||
setHoveredDeckFleetMmsis,
|
||||
onClearFleetHover,
|
||||
hoveredFleetOwnerKey,
|
||||
hoveredFleetMmsiSet,
|
||||
]);
|
||||
|
||||
const clearDeckHoverPairs = useCallback(() => {
|
||||
const prev = mapDeckPairHoverRef.current;
|
||||
mapDeckPairHoverRef.current = [];
|
||||
setHoveredDeckPairMmsiSet((prevState) => (prevState.length === 0 ? prevState : []));
|
||||
}, [setHoveredDeckPairMmsiSet]);
|
||||
if (mapDrivenPairHoverRef.current && prev.length > 0 && equalNumberArrays(hoveredPairMmsiSet, prev)) {
|
||||
onClearPairHover?.();
|
||||
}
|
||||
mapDrivenPairHoverRef.current = false;
|
||||
}, [setHoveredDeckPairMmsiSet, onClearPairHover, hoveredPairMmsiSet]);
|
||||
|
||||
const clearDeckHoverMmsi = useCallback(() => {
|
||||
const prev = mapDeckMmsiHoverRef.current;
|
||||
mapDeckMmsiHoverRef.current = [];
|
||||
setHoveredDeckMmsiSet((prevState) => (prevState.length === 0 ? prevState : []));
|
||||
}, [setHoveredDeckMmsiSet]);
|
||||
if (mapDrivenMmsiHoverRef.current && prev.length > 0 && equalNumberArrays(hoveredMmsiSet, prev)) {
|
||||
onClearMmsiHover?.();
|
||||
}
|
||||
mapDrivenMmsiHoverRef.current = false;
|
||||
}, [setHoveredDeckMmsiSet, onClearMmsiHover, hoveredMmsiSet]);
|
||||
|
||||
const scheduleDeckHoverResolve = useCallback(() => {
|
||||
if (deckHoverRafRef.current != null) return;
|
||||
@ -324,21 +382,41 @@ export function Map3D({
|
||||
const setDeckHoverMmsi = useCallback(
|
||||
(next: number[]) => {
|
||||
const normalized = makeUniqueSorted(next);
|
||||
const prev = mapDeckMmsiHoverRef.current;
|
||||
touchDeckHoverState(normalized.length > 0);
|
||||
setHoveredDeckMmsiSet((prev) => (equalNumberArrays(prev, normalized) ? prev : normalized));
|
||||
mapDeckMmsiHoverRef.current = normalized;
|
||||
if (!equalNumberArrays(prev, normalized)) {
|
||||
if (normalized.length > 0) {
|
||||
mapDrivenMmsiHoverRef.current = true;
|
||||
onHoverMmsi?.(normalized);
|
||||
} else if (mapDrivenMmsiHoverRef.current && prev.length > 0) {
|
||||
onClearMmsiHover?.();
|
||||
mapDrivenMmsiHoverRef.current = false;
|
||||
}
|
||||
}
|
||||
},
|
||||
[setHoveredDeckMmsiSet, touchDeckHoverState],
|
||||
[setHoveredDeckMmsiSet, touchDeckHoverState, onHoverMmsi, onClearMmsiHover],
|
||||
);
|
||||
|
||||
const setDeckHoverPairs = useCallback(
|
||||
(next: number[]) => {
|
||||
const normalized = makeUniqueSorted(next);
|
||||
const prev = mapDeckPairHoverRef.current;
|
||||
touchDeckHoverState(normalized.length > 0);
|
||||
setHoveredDeckPairMmsiSet((prev) => (equalNumberArrays(prev, normalized) ? prev : normalized));
|
||||
mapDeckPairHoverRef.current = normalized;
|
||||
if (!equalNumberArrays(prev, normalized)) {
|
||||
if (normalized.length > 0) {
|
||||
mapDrivenPairHoverRef.current = true;
|
||||
onHoverPair?.(normalized);
|
||||
} else if (mapDrivenPairHoverRef.current && prev.length > 0) {
|
||||
onClearPairHover?.();
|
||||
mapDrivenPairHoverRef.current = false;
|
||||
}
|
||||
}
|
||||
},
|
||||
[setHoveredDeckPairMmsiSet, touchDeckHoverState],
|
||||
[setHoveredDeckPairMmsiSet, touchDeckHoverState, onHoverPair, onClearPairHover],
|
||||
);
|
||||
|
||||
const setMapFleetHoverState = useCallback(
|
||||
@ -352,8 +430,21 @@ export function Map3D({
|
||||
setHoveredDeckFleetOwner(ownerKey);
|
||||
setHoveredDeckFleetMmsis(normalized);
|
||||
mapFleetHoverStateRef.current = { ownerKey, vesselMmsis: normalized };
|
||||
if (ownerKey != null || normalized.length > 0) {
|
||||
mapDrivenFleetHoverRef.current = true;
|
||||
onHoverFleet?.(ownerKey, normalized);
|
||||
} else if (mapDrivenFleetHoverRef.current) {
|
||||
onClearFleetHover?.();
|
||||
mapDrivenFleetHoverRef.current = false;
|
||||
}
|
||||
},
|
||||
[setHoveredDeckFleetOwner, setHoveredDeckFleetMmsis, touchDeckHoverState],
|
||||
[
|
||||
setHoveredDeckFleetOwner,
|
||||
setHoveredDeckFleetMmsis,
|
||||
touchDeckHoverState,
|
||||
onHoverFleet,
|
||||
onClearFleetHover,
|
||||
],
|
||||
);
|
||||
|
||||
// hover RAF cleanup
|
||||
@ -401,48 +492,48 @@ export function Map3D({
|
||||
}, [pairLinks]);
|
||||
|
||||
const pairLinksInteractive = useMemo(() => {
|
||||
if (!overlays.pairLines || (pairLinks?.length ?? 0) === 0) return [];
|
||||
if (hoveredPairMmsiSetRef.size < 2) return [];
|
||||
if ((pairLinks?.length ?? 0) === 0) return [];
|
||||
if (effectiveHoveredPairMmsiSet.size < 2) return [];
|
||||
const links = pairLinks || [];
|
||||
return links.filter((link) =>
|
||||
hoveredPairMmsiSetRef.has(link.aMmsi) && hoveredPairMmsiSetRef.has(link.bMmsi),
|
||||
effectiveHoveredPairMmsiSet.has(link.aMmsi) && effectiveHoveredPairMmsiSet.has(link.bMmsi),
|
||||
);
|
||||
}, [pairLinks, hoveredPairMmsiSetRef, overlays.pairLines]);
|
||||
}, [pairLinks, effectiveHoveredPairMmsiSet]);
|
||||
|
||||
const pairRangesInteractive = useMemo(() => {
|
||||
if (!overlays.pairRange || pairRanges.length === 0) return [];
|
||||
if (hoveredPairMmsiSetRef.size < 2) return [];
|
||||
if (pairRanges.length === 0) return [];
|
||||
if (effectiveHoveredPairMmsiSet.size < 2) return [];
|
||||
return pairRanges.filter((range) =>
|
||||
hoveredPairMmsiSetRef.has(range.aMmsi) && hoveredPairMmsiSetRef.has(range.bMmsi),
|
||||
effectiveHoveredPairMmsiSet.has(range.aMmsi) && effectiveHoveredPairMmsiSet.has(range.bMmsi),
|
||||
);
|
||||
}, [pairRanges, hoveredPairMmsiSetRef, overlays.pairRange]);
|
||||
}, [pairRanges, effectiveHoveredPairMmsiSet]);
|
||||
|
||||
const fcLinesInteractive = useMemo(() => {
|
||||
if (!overlays.fcLines || fcDashed.length === 0) return [];
|
||||
if (fcDashed.length === 0) return [];
|
||||
if (highlightedMmsiSetCombined.size === 0) return [];
|
||||
return fcDashed.filter(
|
||||
(line) =>
|
||||
[line.fromMmsi, line.toMmsi].some((mmsi) => (mmsi == null ? false : highlightedMmsiSetCombined.has(mmsi))),
|
||||
);
|
||||
}, [fcDashed, overlays.fcLines, highlightedMmsiSetCombined]);
|
||||
}, [fcDashed, highlightedMmsiSetCombined]);
|
||||
|
||||
const fleetCirclesInteractive = useMemo(() => {
|
||||
if (!overlays.fleetCircles || (fleetCircles?.length ?? 0) === 0) return [];
|
||||
if ((fleetCircles?.length ?? 0) === 0) return [];
|
||||
if (hoveredFleetOwnerKeys.size === 0 && highlightedMmsiSetCombined.size === 0) return [];
|
||||
const circles = fleetCircles || [];
|
||||
return circles.filter((item) => isHighlightedFleet(item.ownerKey, item.vesselMmsis));
|
||||
}, [fleetCircles, hoveredFleetOwnerKeys, isHighlightedFleet, overlays.fleetCircles, highlightedMmsiSetCombined]);
|
||||
}, [fleetCircles, hoveredFleetOwnerKeys, isHighlightedFleet, highlightedMmsiSetCombined]);
|
||||
|
||||
// ── Hook orchestration ───────────────────────────────────────────────
|
||||
const { ensureMercatorOverlay, clearGlobeNativeLayers, pulseMapSync } = useMapInit(
|
||||
const { ensureMercatorOverlay, pulseMapSync } = useMapInit(
|
||||
containerRef, mapRef, overlayRef, overlayInteractionRef, globeDeckLayerRef,
|
||||
baseMapRef, projectionRef,
|
||||
{ baseMap, projection, showSeamark: settings.showSeamark, onViewBboxChange, setMapSyncEpoch },
|
||||
{ baseMap, projection, showSeamark: settings.showSeamark, onViewBboxChange, setMapSyncEpoch, initialView, onViewStateChange },
|
||||
);
|
||||
|
||||
const reorderGlobeFeatureLayers = useProjectionToggle(
|
||||
mapRef, overlayRef, overlayInteractionRef, globeDeckLayerRef, projectionBusyRef,
|
||||
{ projection, clearGlobeNativeLayers, ensureMercatorOverlay, onProjectionLoadingChange, pulseMapSync, setMapSyncEpoch },
|
||||
{ projection, ensureMercatorOverlay, onProjectionLoadingChange, pulseMapSync, setMapSyncEpoch },
|
||||
);
|
||||
|
||||
useBaseMapToggle(
|
||||
@ -468,10 +559,11 @@ export function Map3D({
|
||||
useGlobeShips(
|
||||
mapRef, projectionBusyRef, reorderGlobeFeatureLayers,
|
||||
{
|
||||
projection, settings, shipData, shipHighlightSet, shipHoverOverlaySet,
|
||||
projection, settings, shipData: shipLayerData, shipHighlightSet, shipHoverOverlaySet,
|
||||
shipOverlayLayerData, shipLayerData, shipByMmsi, mapSyncEpoch,
|
||||
onSelectMmsi, onToggleHighlightMmsi, targets, overlays,
|
||||
onSelectMmsi, onToggleHighlightMmsi, targets: shipLayerData, overlays,
|
||||
legacyHits, selectedMmsi, isBaseHighlightedMmsi, hasAuxiliarySelectModifier,
|
||||
onGlobeShipsReady,
|
||||
},
|
||||
);
|
||||
|
||||
@ -496,7 +588,7 @@ export function Map3D({
|
||||
useDeckLayers(
|
||||
mapRef, overlayRef, globeDeckLayerRef, projectionBusyRef,
|
||||
{
|
||||
projection, settings, shipLayerData, shipOverlayLayerData, shipData,
|
||||
projection, settings, trackReplayDeckLayers: trackReplayRenderState.trackReplayDeckLayers, shipLayerData, shipOverlayLayerData, shipData,
|
||||
legacyHits, pairLinks, fcLinks, fcDashed, fleetCircles, pairRanges,
|
||||
pairLinksInteractive, pairRangesInteractive, fcLinesInteractive, fleetCirclesInteractive,
|
||||
overlays, shipByMmsi, selectedMmsi, shipHighlightSet,
|
||||
@ -523,10 +615,88 @@ export function Map3D({
|
||||
},
|
||||
);
|
||||
|
||||
useTrackReplayLayer(
|
||||
mapRef, projectionBusyRef, reorderGlobeFeatureLayers,
|
||||
{ activeTrack, projection, mapSyncEpoch, renderState: trackReplayRenderState },
|
||||
);
|
||||
|
||||
// 우클릭 컨텍스트 메뉴 — 대상선박(legacyHits)만 허용
|
||||
// Mercator: Deck.gl hover 상태에서 MMSI 참조, Globe: queryRenderedFeatures
|
||||
const hoveredDeckMmsiRef = useRef(hoveredDeckMmsiArr);
|
||||
useEffect(() => { hoveredDeckMmsiRef.current = hoveredDeckMmsiArr; }, [hoveredDeckMmsiArr]);
|
||||
|
||||
useEffect(() => {
|
||||
const container = containerRef.current;
|
||||
if (!container) return;
|
||||
const onContextMenu = (e: MouseEvent) => {
|
||||
e.preventDefault();
|
||||
if (!onOpenTrackMenu) return;
|
||||
const map = mapRef.current;
|
||||
if (!map || !map.isStyleLoaded() || projectionBusyRef.current) return;
|
||||
|
||||
let mmsi: number | null = null;
|
||||
|
||||
if (projectionRef.current === 'globe') {
|
||||
// Globe: MapLibre 네이티브 레이어에서 쿼리
|
||||
const point: [number, number] = [e.offsetX, e.offsetY];
|
||||
const shipLayerIds = [
|
||||
'ships-globe', 'ships-globe-lite', 'ships-globe-halo', 'ships-globe-outline',
|
||||
].filter((id) => map.getLayer(id));
|
||||
|
||||
let features: maplibregl.MapGeoJSONFeature[] = [];
|
||||
try {
|
||||
if (shipLayerIds.length > 0) {
|
||||
features = map.queryRenderedFeatures(point, { layers: shipLayerIds });
|
||||
}
|
||||
} catch { /* ignore */ }
|
||||
|
||||
if (features.length > 0) {
|
||||
const props = features[0].properties || {};
|
||||
const raw = typeof props.mmsi === 'number' ? props.mmsi : Number(props.mmsi);
|
||||
if (Number.isFinite(raw) && raw > 0) mmsi = raw;
|
||||
}
|
||||
} else {
|
||||
// Mercator: Deck.gl hover 상태에서 현재 호버된 MMSI 사용
|
||||
const hovered = hoveredDeckMmsiRef.current;
|
||||
if (hovered.length > 0) mmsi = hovered[0];
|
||||
}
|
||||
|
||||
if (mmsi == null || !legacyHits?.has(mmsi)) return;
|
||||
|
||||
const target = shipByMmsi.get(mmsi);
|
||||
const vesselName = (target?.name || '').trim() || `MMSI ${mmsi}`;
|
||||
onOpenTrackMenu({ x: e.clientX, y: e.clientY, mmsi, vesselName });
|
||||
};
|
||||
container.addEventListener('contextmenu', onContextMenu);
|
||||
return () => container.removeEventListener('contextmenu', onContextMenu);
|
||||
}, [onOpenTrackMenu, legacyHits, shipByMmsi]);
|
||||
|
||||
useFlyTo(
|
||||
mapRef, projectionRef,
|
||||
{ selectedMmsi, shipData, fleetFocusId, fleetFocusLon, fleetFocusLat, fleetFocusZoom },
|
||||
);
|
||||
|
||||
return <div ref={containerRef} style={{ width: '100%', height: '100%' }} />;
|
||||
// Map ready 콜백 — mapSyncEpoch 초기 증가 시 1회 호출
|
||||
const mapReadyFiredRef = useRef(false);
|
||||
useEffect(() => {
|
||||
if (mapReadyFiredRef.current || !onMapReady || !mapRef.current) return;
|
||||
mapReadyFiredRef.current = true;
|
||||
onMapReady(mapRef.current);
|
||||
}, [mapSyncEpoch, onMapReady]);
|
||||
|
||||
return (
|
||||
<>
|
||||
<div ref={containerRef} style={{ width: '100%', height: '100%' }} />
|
||||
{trackContextMenu && onRequestTrack && onCloseTrackMenu && (
|
||||
<VesselContextMenu
|
||||
x={trackContextMenu.x}
|
||||
y={trackContextMenu.y}
|
||||
mmsi={trackContextMenu.mmsi}
|
||||
vesselName={trackContextMenu.vesselName}
|
||||
onRequestTrack={onRequestTrack}
|
||||
onClose={onCloseTrackMenu}
|
||||
/>
|
||||
)}
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
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>
|
||||
);
|
||||
}
|
||||
@ -15,18 +15,9 @@ const OVERLAY_FLEET_RANGE_RGB = OVERLAY_RGB.fleetRange;
|
||||
const OVERLAY_SUSPICIOUS_RGB = OVERLAY_RGB.suspicious;
|
||||
|
||||
// ── Ship icon mapping (Deck.gl IconLayer) ──
|
||||
// Canonical source: shared/lib/map/mapConstants.ts (re-exported for local usage)
|
||||
|
||||
export const SHIP_ICON_MAPPING = {
|
||||
ship: {
|
||||
x: 0,
|
||||
y: 0,
|
||||
width: 128,
|
||||
height: 128,
|
||||
anchorX: 64,
|
||||
anchorY: 64,
|
||||
mask: true,
|
||||
},
|
||||
} as const;
|
||||
export { SHIP_ICON_MAPPING } from '../../shared/lib/map/mapConstants';
|
||||
|
||||
// ── Ship constants ──
|
||||
|
||||
@ -70,10 +61,8 @@ export const DECK_VIEW_ID = 'mapbox';
|
||||
|
||||
// ── Depth params ──
|
||||
|
||||
export const DEPTH_DISABLED_PARAMS = {
|
||||
depthCompare: 'always',
|
||||
depthWriteEnabled: false,
|
||||
} as const;
|
||||
// Canonical source: shared/lib/map/mapConstants.ts (re-exported for local usage)
|
||||
export { DEPTH_DISABLED_PARAMS } from '../../shared/lib/map/mapConstants';
|
||||
|
||||
export const GLOBE_OVERLAY_PARAMS = {
|
||||
depthCompare: 'less-equal',
|
||||
@ -104,7 +93,7 @@ export const FLEET_RANGE_LINE_DECK: [number, number, number, number] = [
|
||||
OVERLAY_FLEET_RANGE_RGB[0], OVERLAY_FLEET_RANGE_RGB[1], OVERLAY_FLEET_RANGE_RGB[2], 140,
|
||||
];
|
||||
export const FLEET_RANGE_FILL_DECK: [number, number, number, number] = [
|
||||
OVERLAY_FLEET_RANGE_RGB[0], OVERLAY_FLEET_RANGE_RGB[1], OVERLAY_FLEET_RANGE_RGB[2], 6,
|
||||
OVERLAY_FLEET_RANGE_RGB[0], OVERLAY_FLEET_RANGE_RGB[1], OVERLAY_FLEET_RANGE_RGB[2], 0,
|
||||
];
|
||||
|
||||
// ── Highlighted variants ──
|
||||
@ -131,7 +120,7 @@ export const FLEET_RANGE_LINE_DECK_HL: [number, number, number, number] = [
|
||||
OVERLAY_FLEET_RANGE_RGB[0], OVERLAY_FLEET_RANGE_RGB[1], OVERLAY_FLEET_RANGE_RGB[2], 220,
|
||||
];
|
||||
export const FLEET_RANGE_FILL_DECK_HL: [number, number, number, number] = [
|
||||
OVERLAY_FLEET_RANGE_RGB[0], OVERLAY_FLEET_RANGE_RGB[1], OVERLAY_FLEET_RANGE_RGB[2], 42,
|
||||
OVERLAY_FLEET_RANGE_RGB[0], OVERLAY_FLEET_RANGE_RGB[1], OVERLAY_FLEET_RANGE_RGB[2], 120,
|
||||
];
|
||||
|
||||
// ── MapLibre overlay colors ──
|
||||
@ -151,8 +140,8 @@ export const FC_LINE_SUSPICIOUS_ML = rgbaCss(OVERLAY_SUSPICIOUS_RGB, 0.95);
|
||||
export const FC_LINE_NORMAL_ML_HL = rgbaCss(OVERLAY_FC_TRANSFER_RGB, 0.98);
|
||||
export const FC_LINE_SUSPICIOUS_ML_HL = rgbaCss(OVERLAY_SUSPICIOUS_RGB, 0.98);
|
||||
|
||||
export const FLEET_FILL_ML = rgbaCss(OVERLAY_FLEET_RANGE_RGB, 0.02);
|
||||
export const FLEET_FILL_ML_HL = rgbaCss(OVERLAY_FLEET_RANGE_RGB, 0.16);
|
||||
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);
|
||||
|
||||
|
||||
@ -110,7 +110,7 @@ export function useBaseMapToggle(
|
||||
if (!map) return;
|
||||
if (showSeamark) {
|
||||
try {
|
||||
ensureSeamarkOverlay(map, 'bathymetry-lines');
|
||||
ensureSeamarkOverlay(map, 'bathymetry-lines-coarse');
|
||||
map.setPaintProperty('seamark', 'raster-opacity', 0.85);
|
||||
} catch {
|
||||
// ignore until style is ready
|
||||
|
||||
@ -1,6 +1,4 @@
|
||||
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';
|
||||
@ -9,39 +7,7 @@ import type { FcLink, FleetCircle, PairLink } from '../../../features/legacyDash
|
||||
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,
|
||||
@ -50,6 +16,13 @@ import {
|
||||
getFleetCircleTooltipHtml,
|
||||
} from '../lib/tooltips';
|
||||
import { sanitizeDeckLayerList } from '../lib/mapCore';
|
||||
import { buildMercatorDeckLayers, buildGlobeDeckLayers } from '../lib/deckLayerFactories';
|
||||
|
||||
// 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>,
|
||||
@ -59,6 +32,7 @@ export function useDeckLayers(
|
||||
opts: {
|
||||
projection: MapProjectionId;
|
||||
settings: Map3DSettings;
|
||||
trackReplayDeckLayers: unknown[];
|
||||
shipLayerData: AisTarget[];
|
||||
shipOverlayLayerData: AisTarget[];
|
||||
shipData: AisTarget[];
|
||||
@ -96,7 +70,7 @@ export function useDeckLayers(
|
||||
},
|
||||
) {
|
||||
const {
|
||||
projection, settings, shipLayerData, shipOverlayLayerData, shipData,
|
||||
projection, settings, trackReplayDeckLayers, shipLayerData, shipOverlayLayerData, shipData,
|
||||
legacyHits, pairLinks, fcDashed, fleetCircles, pairRanges,
|
||||
pairLinksInteractive, pairRangesInteractive, fcLinesInteractive, fleetCirclesInteractive,
|
||||
overlays, shipByMmsi, selectedMmsi, shipHighlightSet,
|
||||
@ -143,345 +117,41 @@ export function useDeckLayers(
|
||||
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);
|
||||
}
|
||||
const layers = buildMercatorDeckLayers({
|
||||
shipLayerData,
|
||||
shipOverlayLayerData,
|
||||
legacyTargetsOrdered,
|
||||
legacyOverlayTargets,
|
||||
legacyHits,
|
||||
pairLinks,
|
||||
fcDashed,
|
||||
fleetCircles,
|
||||
pairRanges,
|
||||
pairLinksInteractive,
|
||||
pairRangesInteractive,
|
||||
fcLinesInteractive,
|
||||
fleetCirclesInteractive,
|
||||
overlays,
|
||||
showDensity: settings.showDensity,
|
||||
showShips: settings.showShips,
|
||||
selectedMmsi,
|
||||
shipHighlightSet,
|
||||
touchDeckHoverState,
|
||||
setDeckHoverPairs,
|
||||
setDeckHoverMmsi,
|
||||
clearDeckHoverPairs,
|
||||
clearMapFleetHoverState,
|
||||
setMapFleetHoverState,
|
||||
toFleetMmsiList,
|
||||
hasAuxiliarySelectModifier,
|
||||
onSelectMmsi,
|
||||
onToggleHighlightMmsi,
|
||||
onDeckSelectOrHighlight,
|
||||
});
|
||||
|
||||
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: '/assets/ship.svg',
|
||||
iconMapping: SHIP_ICON_MAPPING,
|
||||
getIcon: () => 'ship',
|
||||
getPosition: (d) => [d.lon, d.lat] as [number, number],
|
||||
getAngle: (d) => -getDisplayHeading({ cog: d.cog, heading: d.heading }),
|
||||
sizeUnits: 'pixels',
|
||||
getSize: () => 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: '/assets/ship.svg',
|
||||
iconMapping: SHIP_ICON_MAPPING,
|
||||
getIcon: () => 'ship',
|
||||
getPosition: (d) => [d.lon, d.lat] as [number, number],
|
||||
getAngle: (d) => -getDisplayHeading({ cog: d.cog, heading: d.heading }),
|
||||
sizeUnits: 'pixels',
|
||||
getSize: (d) => {
|
||||
if (selectedMmsi != null && d.mmsi === selectedMmsi) return FLAT_SHIP_ICON_SIZE_SELECTED;
|
||||
if (shipHighlightSet.has(d.mmsi)) return FLAT_SHIP_ICON_SIZE_HIGHLIGHTED;
|
||||
return 0;
|
||||
},
|
||||
getColor: (d) => 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: '/assets/ship.svg',
|
||||
iconMapping: SHIP_ICON_MAPPING,
|
||||
getIcon: () => 'ship',
|
||||
getPosition: (d) => [d.lon, d.lat] as [number, number],
|
||||
getAngle: (d) => -getDisplayHeading({ cog: d.cog, heading: d.heading }),
|
||||
sizeUnits: 'pixels',
|
||||
getSize: () => 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 (overlays.pairRange && 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 (overlays.pairLines && 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 (overlays.fcLines && 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 (overlays.fleetCircles && 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: '/assets/ship.svg', iconMapping: SHIP_ICON_MAPPING, getIcon: () => 'ship', getPosition: (d) => [d.lon, d.lat] as [number, number], getAngle: (d) => -getDisplayHeading({ cog: d.cog, heading: d.heading }), sizeUnits: 'pixels', getSize: (d) => { if (selectedMmsi != null && d.mmsi === selectedMmsi) return FLAT_SHIP_ICON_SIZE_SELECTED; if (shipHighlightSet.has(d.mmsi)) return FLAT_SHIP_ICON_SIZE_HIGHLIGHTED; return 0; }, getColor: (d) => { if (!shipHighlightSet.has(d.mmsi) && !(selectedMmsi != null && d.mmsi === selectedMmsi)) return [0, 0, 0, 0]; return getShipColor(d, selectedMmsi, legacyHits?.get(d.mmsi)?.shipCode ?? null, shipHighlightSet); } }));
|
||||
}
|
||||
|
||||
const normalizedLayers = sanitizeDeckLayerList(layers);
|
||||
const normalizedBaseLayers = sanitizeDeckLayerList(layers);
|
||||
const normalizedTrackLayers = sanitizeDeckLayerList(trackReplayDeckLayers);
|
||||
const normalizedLayers = sanitizeDeckLayerList([...normalizedBaseLayers, ...normalizedTrackLayers]);
|
||||
const deckProps = {
|
||||
layers: normalizedLayers,
|
||||
getTooltip: (info: PickingInfo) => {
|
||||
@ -546,12 +216,7 @@ export function useDeckLayers(
|
||||
try {
|
||||
deckTarget.setProps(deckProps as never);
|
||||
} catch (e) {
|
||||
console.error('Failed to apply base mercator deck props. Falling back to empty layer set.', e);
|
||||
try {
|
||||
deckTarget.setProps({ ...deckProps, layers: [] as unknown[] } as never);
|
||||
} catch {
|
||||
// Ignore secondary failure.
|
||||
}
|
||||
console.error('Failed to apply base mercator deck props. Keeping previous layer set.', e);
|
||||
}
|
||||
}, [
|
||||
ensureMercatorOverlay,
|
||||
@ -576,6 +241,7 @@ export function useDeckLayers(
|
||||
overlays.fleetCircles,
|
||||
settings.showDensity,
|
||||
settings.showShips,
|
||||
trackReplayDeckLayers,
|
||||
onDeckSelectOrHighlight,
|
||||
onSelectMmsi,
|
||||
onToggleHighlightMmsi,
|
||||
@ -595,31 +261,37 @@ export function useDeckLayers(
|
||||
const deckTarget = globeDeckLayerRef.current;
|
||||
if (!deckTarget) 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 (!ENABLE_GLOBE_DECK_OVERLAYS) {
|
||||
try {
|
||||
deckTarget.setProps({ layers: [], getTooltip: undefined, onClick: undefined } as never);
|
||||
} catch {
|
||||
// ignore
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
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 globeLayers = buildGlobeDeckLayers({
|
||||
pairRanges,
|
||||
pairLinks,
|
||||
fcDashed,
|
||||
fleetCircles,
|
||||
legacyTargetsOrdered,
|
||||
legacyHits,
|
||||
overlays,
|
||||
showShips: settings.showShips,
|
||||
selectedMmsi,
|
||||
isHighlightedFleet,
|
||||
isHighlightedPair,
|
||||
isHighlightedMmsi,
|
||||
touchDeckHoverState,
|
||||
setDeckHoverPairs,
|
||||
setDeckHoverMmsi,
|
||||
clearDeckHoverPairs,
|
||||
clearDeckHoverMmsi,
|
||||
clearMapFleetHoverState,
|
||||
setMapFleetHoverState,
|
||||
toFleetMmsiList,
|
||||
});
|
||||
|
||||
const normalizedLayers = sanitizeDeckLayerList(globeLayers);
|
||||
const globeDeckProps = { layers: normalizedLayers, getTooltip: undefined, onClick: undefined };
|
||||
|
||||
356
apps/web/src/widgets/map3d/hooks/useGlobeFcFleetOverlay.ts
Normal file
356
apps/web/src/widgets/map3d/hooks/useGlobeFcFleetOverlay.ts
Normal file
@ -0,0 +1,356 @@
|
||||
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 } from '../../../features/legacyDashboard/model/types';
|
||||
import type { MapToggleState } from '../../../features/mapToggles/MapToggles';
|
||||
import type { DashSeg, MapProjectionId } from '../types';
|
||||
import {
|
||||
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 {
|
||||
makeFcSegmentFeatureId,
|
||||
makeFleetCircleFeatureId,
|
||||
} from '../lib/featureIds';
|
||||
import {
|
||||
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';
|
||||
|
||||
/** Globe FC lines + fleet circles 오버레이 */
|
||||
export function useGlobeFcFleetOverlay(
|
||||
mapRef: MutableRefObject<maplibregl.Map | null>,
|
||||
projectionBusyRef: MutableRefObject<boolean>,
|
||||
reorderGlobeFeatureLayers: () => void,
|
||||
opts: {
|
||||
overlays: MapToggleState;
|
||||
fcLinks: FcLink[] | undefined;
|
||||
fleetCircles: FleetCircle[] | undefined;
|
||||
projection: MapProjectionId;
|
||||
mapSyncEpoch: number;
|
||||
hoveredFleetMmsiList: number[];
|
||||
hoveredFleetOwnerKeyList: string[];
|
||||
hoveredPairMmsiList: number[];
|
||||
},
|
||||
) {
|
||||
const {
|
||||
overlays, fcLinks, fleetCircles, projection, mapSyncEpoch,
|
||||
hoveredFleetMmsiList, hoveredFleetOwnerKeyList, hoveredPairMmsiList,
|
||||
} = opts;
|
||||
|
||||
// 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';
|
||||
|
||||
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,
|
||||
]);
|
||||
|
||||
// FC + Fleet paint state updates
|
||||
// eslint-disable-next-line react-hooks/preserve-manual-memoization
|
||||
const updateFcFleetPaintStates = useCallback(() => {
|
||||
if (projection !== 'globe' || projectionBusyRef.current) return;
|
||||
const map = mapRef.current;
|
||||
if (!map || !map.isStyleLoaded()) return;
|
||||
|
||||
const fleetAwarePairMmsiList = makeUniqueSorted([...hoveredPairMmsiList, ...hoveredFleetMmsiList]);
|
||||
|
||||
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('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('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, updateFcFleetPaintStates);
|
||||
updateFcFleetPaintStates();
|
||||
return () => {
|
||||
stop();
|
||||
};
|
||||
}, [mapSyncEpoch, hoveredFleetMmsiList, hoveredFleetOwnerKeyList, hoveredPairMmsiList, projection, updateFcFleetPaintStates]);
|
||||
}
|
||||
@ -118,7 +118,7 @@ export function useGlobeInteraction(
|
||||
});
|
||||
}
|
||||
|
||||
if (layerId === 'fleet-circles-ml' || layerId === 'fleet-circles-ml-fill') {
|
||||
if (layerId === 'fleet-circles-ml') {
|
||||
return getFleetCircleTooltipHtml({
|
||||
ownerKey: String(props.ownerKey ?? ''),
|
||||
ownerLabel: String(props.ownerLabel ?? props.ownerKey ?? ''),
|
||||
@ -184,9 +184,9 @@ export function useGlobeInteraction(
|
||||
let candidateLayerIds: string[] = [];
|
||||
try {
|
||||
candidateLayerIds = [
|
||||
'ships-globe', 'ships-globe-halo', 'ships-globe-outline',
|
||||
'ships-globe', 'ships-globe-lite', 'ships-globe-halo', 'ships-globe-outline',
|
||||
'pair-lines-ml', 'fc-lines-ml',
|
||||
'fleet-circles-ml', 'fleet-circles-ml-fill',
|
||||
'fleet-circles-ml',
|
||||
'pair-range-ml',
|
||||
'zones-fill', 'zones-line', 'zones-label',
|
||||
].filter((id) => map.getLayer(id));
|
||||
@ -211,9 +211,9 @@ export function useGlobeInteraction(
|
||||
}
|
||||
|
||||
const priority = [
|
||||
'ships-globe', 'ships-globe-halo', 'ships-globe-outline',
|
||||
'ships-globe', 'ships-globe-lite', 'ships-globe-halo', 'ships-globe-outline',
|
||||
'pair-lines-ml', 'fc-lines-ml', 'pair-range-ml',
|
||||
'fleet-circles-ml-fill', 'fleet-circles-ml',
|
||||
'fleet-circles-ml',
|
||||
'zones-fill', 'zones-line', 'zones-label',
|
||||
];
|
||||
|
||||
@ -229,10 +229,14 @@ export function useGlobeInteraction(
|
||||
|
||||
const layerId = first.layer?.id;
|
||||
const props = first.properties || {};
|
||||
const isShipLayer = layerId === 'ships-globe' || layerId === 'ships-globe-halo' || layerId === 'ships-globe-outline';
|
||||
const isShipLayer =
|
||||
layerId === 'ships-globe' ||
|
||||
layerId === 'ships-globe-lite' ||
|
||||
layerId === 'ships-globe-halo' ||
|
||||
layerId === 'ships-globe-outline';
|
||||
const isPairLayer = layerId === 'pair-lines-ml' || layerId === 'pair-range-ml';
|
||||
const isFcLayer = layerId === 'fc-lines-ml';
|
||||
const isFleetLayer = layerId === 'fleet-circles-ml' || layerId === 'fleet-circles-ml-fill';
|
||||
const isFleetLayer = layerId === 'fleet-circles-ml';
|
||||
const isZoneLayer = layerId === 'zones-fill' || layerId === 'zones-line' || layerId === 'zones-label';
|
||||
|
||||
if (isShipLayer) {
|
||||
|
||||
@ -1,34 +1,10 @@
|
||||
import { useCallback, useEffect, type MutableRefObject } from 'react';
|
||||
import 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, 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 { dashifyLine } from '../lib/dashifyLine';
|
||||
import type { MapProjectionId } from '../types';
|
||||
import { useGlobePairOverlay } from './useGlobePairOverlay';
|
||||
import { useGlobeFcFleetOverlay } from './useGlobeFcFleetOverlay';
|
||||
|
||||
export function useGlobeOverlays(
|
||||
mapRef: MutableRefObject<maplibregl.Map | null>,
|
||||
@ -46,573 +22,24 @@ export function useGlobeOverlays(
|
||||
hoveredPairMmsiList: number[];
|
||||
},
|
||||
) {
|
||||
const {
|
||||
overlays, pairLinks, fcLinks, fleetCircles, projection, mapSyncEpoch,
|
||||
hoveredFleetMmsiList, hoveredFleetOwnerKeyList, hoveredPairMmsiList,
|
||||
} = opts;
|
||||
// Pair lines + pair range
|
||||
useGlobePairOverlay(mapRef, projectionBusyRef, reorderGlobeFeatureLayers, {
|
||||
overlays: opts.overlays,
|
||||
pairLinks: opts.pairLinks,
|
||||
projection: opts.projection,
|
||||
mapSyncEpoch: opts.mapSyncEpoch,
|
||||
hoveredPairMmsiList: opts.hoveredPairMmsiList,
|
||||
});
|
||||
|
||||
// Pair lines
|
||||
useEffect(() => {
|
||||
const map = mapRef.current;
|
||||
if (!map) return;
|
||||
|
||||
const srcId = 'pair-lines-ml-src';
|
||||
const layerId = 'pair-lines-ml';
|
||||
|
||||
const remove = () => {
|
||||
try {
|
||||
if (map.getLayer(layerId)) map.setLayoutProperty(layerId, 'visibility', 'none');
|
||||
} catch {
|
||||
// ignore
|
||||
}
|
||||
};
|
||||
|
||||
const ensure = () => {
|
||||
if (projectionBusyRef.current) return;
|
||||
if (!map.isStyleLoaded()) return;
|
||||
if (projection !== 'globe' || !overlays.pairLines || (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 {
|
||||
try {
|
||||
map.setLayoutProperty(layerId, 'visibility', 'visible');
|
||||
} catch {
|
||||
// ignore
|
||||
}
|
||||
}
|
||||
|
||||
reorderGlobeFeatureLayers();
|
||||
kickRepaint(map);
|
||||
};
|
||||
|
||||
const stop = onMapStyleReady(map, ensure);
|
||||
ensure();
|
||||
return () => {
|
||||
stop();
|
||||
};
|
||||
}, [projection, overlays.pairLines, pairLinks, 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 = () => {
|
||||
try {
|
||||
if (map.getLayer(layerId)) map.setLayoutProperty(layerId, 'visibility', 'none');
|
||||
} catch {
|
||||
// ignore
|
||||
}
|
||||
};
|
||||
|
||||
const ensure = () => {
|
||||
if (projectionBusyRef.current) return;
|
||||
if (!map.isStyleLoaded()) return;
|
||||
if (projection !== 'globe' || !overlays.fcLines) {
|
||||
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 {
|
||||
try {
|
||||
map.setLayoutProperty(layerId, 'visibility', 'visible');
|
||||
} catch {
|
||||
// ignore
|
||||
}
|
||||
}
|
||||
|
||||
reorderGlobeFeatureLayers();
|
||||
kickRepaint(map);
|
||||
};
|
||||
|
||||
const stop = onMapStyleReady(map, ensure);
|
||||
ensure();
|
||||
return () => {
|
||||
stop();
|
||||
};
|
||||
}, [projection, overlays.fcLines, fcLinks, 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';
|
||||
|
||||
const remove = () => {
|
||||
try {
|
||||
if (map.getLayer(fillLayerId)) map.setLayoutProperty(fillLayerId, 'visibility', 'none');
|
||||
} catch {
|
||||
// ignore
|
||||
}
|
||||
try {
|
||||
if (map.getLayer(layerId)) map.setLayoutProperty(layerId, 'visibility', 'none');
|
||||
} catch {
|
||||
// ignore
|
||||
}
|
||||
};
|
||||
|
||||
const ensure = () => {
|
||||
if (projectionBusyRef.current) return;
|
||||
if (!map.isStyleLoaded()) return;
|
||||
if (projection !== 'globe' || !overlays.fleetCircles || (fleetCircles?.length ?? 0) === 0) {
|
||||
remove();
|
||||
return;
|
||||
}
|
||||
|
||||
const fcLine: GeoJSON.FeatureCollection<GeoJSON.LineString> = {
|
||||
type: 'FeatureCollection',
|
||||
features: (fleetCircles || []).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: (fleetCircles || []).map((c) => {
|
||||
const ring = circleRingLngLat(c.center, c.radiusNm * 1852);
|
||||
return {
|
||||
type: 'Feature',
|
||||
id: `${makeFleetCircleFeatureId(c.ownerKey)}-fill`,
|
||||
geometry: { type: 'Polygon', coordinates: [ring] },
|
||||
properties: {
|
||||
type: 'fleet-fill',
|
||||
ownerKey: c.ownerKey,
|
||||
ownerLabel: c.ownerLabel,
|
||||
count: c.count,
|
||||
vesselMmsis: c.vesselMmsis,
|
||||
highlighted: 0,
|
||||
},
|
||||
};
|
||||
}),
|
||||
};
|
||||
|
||||
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 source setup failed:', e);
|
||||
return;
|
||||
}
|
||||
|
||||
if (!map.getLayer(fillLayerId)) {
|
||||
try {
|
||||
map.addLayer(
|
||||
{
|
||||
id: fillLayerId,
|
||||
type: 'fill',
|
||||
source: fillSrcId,
|
||||
layout: { visibility: 'visible' },
|
||||
paint: {
|
||||
'fill-color': ['case', ['==', ['get', 'highlighted'], 1], FLEET_FILL_ML_HL, FLEET_FILL_ML] as never,
|
||||
'fill-opacity': ['case', ['==', ['get', 'highlighted'], 1], 0.7, 0.36] as never,
|
||||
},
|
||||
} as unknown as LayerSpecification,
|
||||
undefined,
|
||||
);
|
||||
} catch (e) {
|
||||
console.warn('Fleet circles fill layer add failed:', e);
|
||||
}
|
||||
} else {
|
||||
try {
|
||||
map.setLayoutProperty(fillLayerId, 'visibility', 'visible');
|
||||
} catch {
|
||||
// ignore
|
||||
}
|
||||
}
|
||||
|
||||
if (!map.getLayer(layerId)) {
|
||||
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 {
|
||||
try {
|
||||
map.setLayoutProperty(layerId, 'visibility', 'visible');
|
||||
} catch {
|
||||
// ignore
|
||||
}
|
||||
}
|
||||
|
||||
reorderGlobeFeatureLayers();
|
||||
kickRepaint(map);
|
||||
};
|
||||
|
||||
const stop = onMapStyleReady(map, ensure);
|
||||
ensure();
|
||||
return () => {
|
||||
stop();
|
||||
};
|
||||
}, [projection, overlays.fleetCircles, fleetCircles, 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 = () => {
|
||||
try {
|
||||
if (map.getLayer(layerId)) map.setLayoutProperty(layerId, 'visibility', 'none');
|
||||
} catch {
|
||||
// ignore
|
||||
}
|
||||
};
|
||||
|
||||
const ensure = () => {
|
||||
if (projectionBusyRef.current) return;
|
||||
if (!map.isStyleLoaded()) return;
|
||||
if (projection !== 'globe' || !overlays.pairRange) {
|
||||
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 {
|
||||
try {
|
||||
map.setLayoutProperty(layerId, 'visibility', 'visible');
|
||||
} catch {
|
||||
// ignore
|
||||
}
|
||||
}
|
||||
|
||||
kickRepaint(map);
|
||||
};
|
||||
|
||||
const stop = onMapStyleReady(map, ensure);
|
||||
ensure();
|
||||
return () => {
|
||||
stop();
|
||||
};
|
||||
}, [projection, overlays.pairRange, pairLinks, 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 {
|
||||
if (map.getLayer('fleet-circles-ml-fill')) {
|
||||
map.setPaintProperty('fleet-circles-ml-fill', 'fill-color', ['case', fleetHighlightExpr, FLEET_FILL_ML_HL, FLEET_FILL_ML] as never);
|
||||
map.setPaintProperty('fleet-circles-ml-fill', 'fill-opacity', ['case', fleetHighlightExpr, 0.7, 0.28] as never);
|
||||
}
|
||||
if (map.getLayer('fleet-circles-ml')) {
|
||||
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]);
|
||||
// FC lines + fleet circles
|
||||
useGlobeFcFleetOverlay(mapRef, projectionBusyRef, reorderGlobeFeatureLayers, {
|
||||
overlays: opts.overlays,
|
||||
fcLinks: opts.fcLinks,
|
||||
fleetCircles: opts.fleetCircles,
|
||||
projection: opts.projection,
|
||||
mapSyncEpoch: opts.mapSyncEpoch,
|
||||
hoveredFleetMmsiList: opts.hoveredFleetMmsiList,
|
||||
hoveredFleetOwnerKeyList: opts.hoveredFleetOwnerKeyList,
|
||||
hoveredPairMmsiList: opts.hoveredPairMmsiList,
|
||||
});
|
||||
}
|
||||
|
||||
284
apps/web/src/widgets/map3d/hooks/useGlobePairOverlay.ts
Normal file
284
apps/web/src/widgets/map3d/hooks/useGlobePairOverlay.ts
Normal file
@ -0,0 +1,284 @@
|
||||
import { useCallback, useEffect, type MutableRefObject } from 'react';
|
||||
import type maplibregl from 'maplibre-gl';
|
||||
import type { GeoJSONSource, GeoJSONSourceSpecification, LayerSpecification } from 'maplibre-gl';
|
||||
import type { PairLink } from '../../../features/legacyDashboard/model/types';
|
||||
import type { MapToggleState } from '../../../features/mapToggles/MapToggles';
|
||||
import type { 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,
|
||||
} from '../constants';
|
||||
import { makePairLinkFeatureId } from '../lib/featureIds';
|
||||
import { makeMmsiPairHighlightExpr } from '../lib/mlExpressions';
|
||||
import { kickRepaint, onMapStyleReady } from '../lib/mapCore';
|
||||
import { circleRingLngLat } from '../lib/geometry';
|
||||
import { guardedSetVisibility } from '../lib/layerHelpers';
|
||||
|
||||
/** Globe pair lines + pair range 오버레이 */
|
||||
export function useGlobePairOverlay(
|
||||
mapRef: MutableRefObject<maplibregl.Map | null>,
|
||||
projectionBusyRef: MutableRefObject<boolean>,
|
||||
reorderGlobeFeatureLayers: () => void,
|
||||
opts: {
|
||||
overlays: MapToggleState;
|
||||
pairLinks: PairLink[] | undefined;
|
||||
projection: MapProjectionId;
|
||||
mapSyncEpoch: number;
|
||||
hoveredPairMmsiList: number[];
|
||||
},
|
||||
) {
|
||||
const { overlays, pairLinks, projection, mapSyncEpoch, 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]);
|
||||
|
||||
// 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]);
|
||||
|
||||
// Pair paint state updates
|
||||
// eslint-disable-next-line react-hooks/preserve-manual-memoization
|
||||
const updatePairPaintStates = useCallback(() => {
|
||||
if (projection !== 'globe' || projectionBusyRef.current) return;
|
||||
const map = mapRef.current;
|
||||
if (!map || !map.isStyleLoaded()) return;
|
||||
|
||||
const pairHighlightExpr = hoveredPairMmsiList.length >= 2
|
||||
? makeMmsiPairHighlightExpr('aMmsi', 'bMmsi', hoveredPairMmsiList)
|
||||
: 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('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
|
||||
}
|
||||
}, [projection, hoveredPairMmsiList]);
|
||||
|
||||
useEffect(() => {
|
||||
const map = mapRef.current;
|
||||
if (!map) return;
|
||||
const stop = onMapStyleReady(map, updatePairPaintStates);
|
||||
updatePairPaintStates();
|
||||
return () => {
|
||||
stop();
|
||||
};
|
||||
}, [mapSyncEpoch, hoveredPairMmsiList, projection, updatePairPaintStates]);
|
||||
}
|
||||
369
apps/web/src/widgets/map3d/hooks/useGlobeShipHover.ts
Normal file
369
apps/web/src/widgets/map3d/hooks/useGlobeShipHover.ts
Normal file
@ -0,0 +1,369 @@
|
||||
import { useEffect, 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 { Map3DSettings, MapProjectionId } from '../types';
|
||||
import {
|
||||
GLOBE_ICON_HEADING_OFFSET_DEG,
|
||||
DEG2RAD,
|
||||
} from '../constants';
|
||||
import { isFiniteNumber } from '../lib/setUtils';
|
||||
import { GLOBE_SHIP_CIRCLE_RADIUS_EXPR } from '../lib/mlExpressions';
|
||||
import { kickRepaint, onMapStyleReady } from '../lib/mapCore';
|
||||
import { getDisplayHeading, getGlobeBaseShipColor } from '../lib/shipUtils';
|
||||
import { ensureFallbackShipImage } from '../lib/globeShipIcon';
|
||||
import { clampNumber } from '../lib/geometry';
|
||||
import { guardedSetVisibility } from '../lib/layerHelpers';
|
||||
|
||||
/** Globe 호버 오버레이 + 클릭 선택 */
|
||||
export function useGlobeShipHover(
|
||||
mapRef: MutableRefObject<maplibregl.Map | null>,
|
||||
projectionBusyRef: MutableRefObject<boolean>,
|
||||
reorderGlobeFeatureLayers: () => void,
|
||||
opts: {
|
||||
projection: MapProjectionId;
|
||||
settings: Map3DSettings;
|
||||
shipLayerData: AisTarget[];
|
||||
shipHoverOverlaySet: Set<number>;
|
||||
legacyHits: Map<number, LegacyVesselInfo> | null | undefined;
|
||||
selectedMmsi: number | null;
|
||||
mapSyncEpoch: number;
|
||||
onSelectMmsi: (mmsi: number | null) => void;
|
||||
onToggleHighlightMmsi?: (mmsi: number) => void;
|
||||
targets: AisTarget[];
|
||||
hasAuxiliarySelectModifier: (ev?: { shiftKey?: boolean; ctrlKey?: boolean; metaKey?: boolean } | null) => boolean;
|
||||
},
|
||||
) {
|
||||
const {
|
||||
projection, settings, shipLayerData, shipHoverOverlaySet, legacyHits,
|
||||
selectedMmsi, mapSyncEpoch, onSelectMmsi, onToggleHighlightMmsi,
|
||||
targets, hasAuxiliarySelectModifier,
|
||||
} = opts;
|
||||
|
||||
const epochRef = useRef(-1);
|
||||
const hoverSignatureRef = useRef('');
|
||||
|
||||
// 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 (epochRef.current !== mapSyncEpoch) {
|
||||
epochRef.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 === hoverSignatureRef.current && hasHoverSource && hasHoverLayers) {
|
||||
return;
|
||||
}
|
||||
hoverSignatureRef.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]);
|
||||
}
|
||||
164
apps/web/src/widgets/map3d/hooks/useGlobeShipLabels.ts
Normal file
164
apps/web/src/widgets/map3d/hooks/useGlobeShipLabels.ts
Normal file
@ -0,0 +1,164 @@
|
||||
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 { kickRepaint, onMapStyleReady } from '../lib/mapCore';
|
||||
|
||||
/** Mercator 모드 선명 라벨 (허가 선박 + 선택/하이라이트) */
|
||||
export function useGlobeShipLabels(
|
||||
mapRef: MutableRefObject<maplibregl.Map | null>,
|
||||
projectionBusyRef: MutableRefObject<boolean>,
|
||||
opts: {
|
||||
projection: MapProjectionId;
|
||||
settings: Map3DSettings;
|
||||
shipData: AisTarget[];
|
||||
shipHighlightSet: Set<number>;
|
||||
overlays: MapToggleState;
|
||||
legacyHits: Map<number, LegacyVesselInfo> | null | undefined;
|
||||
selectedMmsi: number | null;
|
||||
mapSyncEpoch: number;
|
||||
},
|
||||
) {
|
||||
const {
|
||||
projection, settings, shipData, shipHighlightSet,
|
||||
overlays, legacyHits, selectedMmsi, mapSyncEpoch,
|
||||
} = opts;
|
||||
|
||||
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,
|
||||
]);
|
||||
}
|
||||
501
apps/web/src/widgets/map3d/hooks/useGlobeShipLayers.ts
Normal file
501
apps/web/src/widgets/map3d/hooks/useGlobeShipLayers.ts
Normal file
@ -0,0 +1,501 @@
|
||||
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,
|
||||
} 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';
|
||||
|
||||
/** Globe 모드 선박 아이콘 레이어 (halo, outline, symbol, label) */
|
||||
export function useGlobeShipLayers(
|
||||
mapRef: MutableRefObject<maplibregl.Map | null>,
|
||||
projectionBusyRef: MutableRefObject<boolean>,
|
||||
reorderGlobeFeatureLayers: () => void,
|
||||
opts: {
|
||||
projection: MapProjectionId;
|
||||
settings: Map3DSettings;
|
||||
shipData: AisTarget[];
|
||||
overlays: MapToggleState;
|
||||
legacyHits: Map<number, LegacyVesselInfo> | null | undefined;
|
||||
selectedMmsi: number | null;
|
||||
isBaseHighlightedMmsi: (mmsi: number) => boolean;
|
||||
mapSyncEpoch: number;
|
||||
onGlobeShipsReady?: (ready: boolean) => void;
|
||||
},
|
||||
) {
|
||||
const {
|
||||
projection, settings, shipData, overlays, legacyHits,
|
||||
selectedMmsi, isBaseHighlightedMmsi, mapSyncEpoch, onGlobeShipsReady,
|
||||
} = opts;
|
||||
|
||||
const epochRef = useRef(-1);
|
||||
|
||||
// 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]);
|
||||
|
||||
// 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 (epochRef.current !== mapSyncEpoch) {
|
||||
epochRef.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);
|
||||
}
|
||||
}
|
||||
|
||||
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);
|
||||
}
|
||||
}
|
||||
|
||||
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);
|
||||
}
|
||||
}
|
||||
|
||||
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);
|
||||
}
|
||||
}
|
||||
|
||||
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);
|
||||
}
|
||||
}
|
||||
|
||||
// 레이어가 준비되었음을 알림 (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,
|
||||
]);
|
||||
}
|
||||
파일 크기가 너무 크기때문에 변경 상태를 표시하지 않습니다.
Load Diff
@ -2,12 +2,11 @@ import { useCallback, useEffect, useRef, type MutableRefObject, type Dispatch, t
|
||||
import maplibregl, { type StyleSpecification } from 'maplibre-gl';
|
||||
import { MapboxOverlay } from '@deck.gl/mapbox';
|
||||
import { MaplibreDeckCustomLayer } from '../MaplibreDeckCustomLayer';
|
||||
import type { BaseMapId, MapProjectionId } from '../types';
|
||||
import { DECK_VIEW_ID } from '../constants';
|
||||
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';
|
||||
import { clearGlobeNativeLayers } from '../lib/layerHelpers';
|
||||
|
||||
export function useMapInit(
|
||||
containerRef: MutableRefObject<HTMLDivElement | null>,
|
||||
@ -23,10 +22,14 @@ export function useMapInit(
|
||||
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]);
|
||||
@ -46,12 +49,6 @@ export function useMapInit(
|
||||
}
|
||||
}, []);
|
||||
|
||||
const clearGlobeNativeLayersCb = useCallback(() => {
|
||||
const map = mapRef.current;
|
||||
if (!map) return;
|
||||
clearGlobeNativeLayers(map);
|
||||
}, []);
|
||||
|
||||
const pulseMapSync = useCallback(() => {
|
||||
setMapSyncEpoch((prev) => prev + 1);
|
||||
requestAnimationFrame(() => {
|
||||
@ -65,6 +62,7 @@ export function useMapInit(
|
||||
|
||||
let map: maplibregl.Map | null = null;
|
||||
let cancelled = false;
|
||||
let viewSaveTimer: ReturnType<typeof setInterval> | null = null;
|
||||
const controller = new AbortController();
|
||||
|
||||
(async () => {
|
||||
@ -77,13 +75,14 @@ export function useMapInit(
|
||||
}
|
||||
if (cancelled || !containerRef.current) return;
|
||||
|
||||
const iv = opts.initialView;
|
||||
map = new maplibregl.Map({
|
||||
container: containerRef.current,
|
||||
style,
|
||||
center: [126.5, 34.2],
|
||||
zoom: 7,
|
||||
pitch: 45,
|
||||
bearing: 0,
|
||||
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,
|
||||
@ -94,19 +93,72 @@ export function useMapInit(
|
||||
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;
|
||||
|
||||
if (projectionRef.current === 'mercator') {
|
||||
const overlay = ensureMercatorOverlay();
|
||||
if (!overlay) return;
|
||||
overlayRef.current = overlay;
|
||||
} else {
|
||||
globeDeckLayerRef.current = new MaplibreDeckCustomLayer({
|
||||
id: 'deck-globe',
|
||||
viewId: DECK_VIEW_ID,
|
||||
deckProps: { layers: [] },
|
||||
});
|
||||
}
|
||||
// 양쪽 overlay를 모두 초기화 — projection 전환 시 재생성 비용 제거
|
||||
ensureMercatorOverlay();
|
||||
globeDeckLayerRef.current = new MaplibreDeckCustomLayer({
|
||||
id: 'deck-globe',
|
||||
viewId: DECK_VIEW_ID,
|
||||
deckProps: { layers: [] },
|
||||
});
|
||||
|
||||
function applyProjection() {
|
||||
if (!map) return;
|
||||
@ -122,8 +174,9 @@ export function useMapInit(
|
||||
|
||||
onMapStyleReady(map, () => {
|
||||
applyProjection();
|
||||
// deck-globe를 항상 추가 (projection과 무관)
|
||||
const deckLayer = globeDeckLayerRef.current;
|
||||
if (projectionRef.current === 'globe' && deckLayer && !map!.getLayer(deckLayer.id)) {
|
||||
if (deckLayer && !map!.getLayer(deckLayer.id)) {
|
||||
try {
|
||||
map!.addLayer(deckLayer);
|
||||
} catch {
|
||||
@ -132,7 +185,7 @@ export function useMapInit(
|
||||
}
|
||||
if (!showSeamarkRef.current) return;
|
||||
try {
|
||||
ensureSeamarkOverlay(map!, 'bathymetry-lines');
|
||||
ensureSeamarkOverlay(map!, 'bathymetry-lines-coarse');
|
||||
} catch {
|
||||
// ignore
|
||||
}
|
||||
@ -147,10 +200,38 @@ export function useMapInit(
|
||||
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');
|
||||
ensureSeamarkOverlay(map!, 'bathymetry-lines-coarse');
|
||||
} catch {
|
||||
// ignore
|
||||
}
|
||||
@ -161,12 +242,22 @@ export function useMapInit(
|
||||
// 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();
|
||||
@ -192,5 +283,5 @@ export function useMapInit(
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, []);
|
||||
|
||||
return { ensureMercatorOverlay, clearGlobeNativeLayers: clearGlobeNativeLayersCb, pulseMapSync };
|
||||
return { ensureMercatorOverlay, pulseMapSync };
|
||||
}
|
||||
|
||||
@ -35,7 +35,7 @@ function applyLabelLanguage(map: maplibregl.Map, lang: MapLabelLanguage) {
|
||||
if (layer.type !== 'symbol') continue;
|
||||
const layout = (layer as { layout?: Record<string, unknown> }).layout;
|
||||
if (!layout?.['text-field']) continue;
|
||||
if (layer.id === 'bathymetry-labels') continue;
|
||||
if (layer.id.startsWith('bathymetry-labels')) continue;
|
||||
const textField =
|
||||
lang === 'local'
|
||||
? ['get', 'name']
|
||||
@ -63,6 +63,7 @@ function applyLandColor(map: maplibregl.Map, color: string) {
|
||||
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);
|
||||
@ -102,17 +103,19 @@ function applyWaterBaseColor(map: maplibregl.Map, fillColor: string) {
|
||||
}
|
||||
|
||||
function applyDepthGradient(map: maplibregl.Map, stops: DepthColorStop[]) {
|
||||
if (!map.getLayer('bathymetry-fill')) return;
|
||||
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];
|
||||
const deepest = sorted[0];
|
||||
if (deepest) expr.push(-11000, darkenHex(deepest.color, 0.5));
|
||||
for (const s of sorted) {
|
||||
expr.push(s.depth, s.color);
|
||||
}
|
||||
// 0m까지 확장 (최천층 stop이 0보다 깊으면)
|
||||
const shallowest = sorted[sorted.length - 1];
|
||||
if (shallowest) expr.push(0, lightenHex(shallowest.color, 1.8));
|
||||
if (shallowest.depth < 0) {
|
||||
expr.push(0, lightenHex(shallowest.color, 1.8));
|
||||
}
|
||||
if (!map.getLayer('bathymetry-fill')) return;
|
||||
try {
|
||||
map.setPaintProperty('bathymetry-fill', 'fill-color', expr as never);
|
||||
} catch {
|
||||
@ -122,7 +125,7 @@ function applyDepthGradient(map: maplibregl.Map, stops: DepthColorStop[]) {
|
||||
|
||||
function applyDepthFontSize(map: maplibregl.Map, size: DepthFontSize) {
|
||||
const expr = DEPTH_FONT_SIZE_MAP[size];
|
||||
for (const layerId of ['bathymetry-labels', 'bathymetry-landforms']) {
|
||||
for (const layerId of ['bathymetry-labels', 'bathymetry-labels-coarse', 'bathymetry-landforms']) {
|
||||
if (!map.getLayer(layerId)) continue;
|
||||
try {
|
||||
map.setLayoutProperty(layerId, 'text-size', expr);
|
||||
@ -133,7 +136,7 @@ function applyDepthFontSize(map: maplibregl.Map, size: DepthFontSize) {
|
||||
}
|
||||
|
||||
function applyDepthFontColor(map: maplibregl.Map, color: string) {
|
||||
for (const layerId of ['bathymetry-labels', 'bathymetry-landforms']) {
|
||||
for (const layerId of ['bathymetry-labels', 'bathymetry-labels-coarse', 'bathymetry-landforms']) {
|
||||
if (!map.getLayer(layerId)) continue;
|
||||
try {
|
||||
map.setPaintProperty(layerId, 'text-color', color);
|
||||
|
||||
@ -84,16 +84,27 @@ export function useNativeMapLayers(
|
||||
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;
|
||||
if (projectionBusyRef.current) return;
|
||||
|
||||
// 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;
|
||||
@ -150,6 +161,10 @@ export function useNativeMapLayers(
|
||||
|
||||
const stop = onMapStyleReady(map, ensure);
|
||||
return () => {
|
||||
cancelled = true;
|
||||
if (retryRaf != null) {
|
||||
cancelAnimationFrame(retryRaf);
|
||||
}
|
||||
stop();
|
||||
};
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
|
||||
@ -3,9 +3,7 @@ import type maplibregl from 'maplibre-gl';
|
||||
import { MapboxOverlay } from '@deck.gl/mapbox';
|
||||
import { MaplibreDeckCustomLayer } from '../MaplibreDeckCustomLayer';
|
||||
import type { MapProjectionId } from '../types';
|
||||
import { DECK_VIEW_ID } from '../constants';
|
||||
import { kickRepaint, onMapStyleReady, extractProjectionType } from '../lib/mapCore';
|
||||
import { removeLayerIfExists } from '../lib/layerHelpers';
|
||||
|
||||
export function useProjectionToggle(
|
||||
mapRef: MutableRefObject<maplibregl.Map | null>,
|
||||
@ -15,14 +13,13 @@ export function useProjectionToggle(
|
||||
projectionBusyRef: MutableRefObject<boolean>,
|
||||
opts: {
|
||||
projection: MapProjectionId;
|
||||
clearGlobeNativeLayers: () => void;
|
||||
ensureMercatorOverlay: () => MapboxOverlay | null;
|
||||
onProjectionLoadingChange?: (loading: boolean) => void;
|
||||
pulseMapSync: () => void;
|
||||
setMapSyncEpoch: (updater: (prev: number) => number) => void;
|
||||
},
|
||||
): () => void {
|
||||
const { projection, clearGlobeNativeLayers, ensureMercatorOverlay, onProjectionLoadingChange, pulseMapSync, setMapSyncEpoch } = opts;
|
||||
const { projection, ensureMercatorOverlay, onProjectionLoadingChange, pulseMapSync, setMapSyncEpoch } = opts;
|
||||
|
||||
const projectionBusyTokenRef = useRef(0);
|
||||
const projectionBusyTimerRef = useRef<ReturnType<typeof window.setTimeout> | null>(null);
|
||||
@ -71,7 +68,7 @@ export function useProjectionToggle(
|
||||
if (!projectionBusyRef.current || projectionBusyTokenRef.current !== token) return;
|
||||
console.debug('Projection loading fallback timeout reached.');
|
||||
endProjectionLoading();
|
||||
}, 4000);
|
||||
}, 2000);
|
||||
},
|
||||
[clearProjectionBusyTimer, endProjectionLoading, onProjectionLoadingChange],
|
||||
);
|
||||
@ -97,6 +94,15 @@ export function useProjectionToggle(
|
||||
'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',
|
||||
@ -106,6 +112,7 @@ export function useProjectionToggle(
|
||||
'predict-vectors-hl',
|
||||
'ships-globe-halo',
|
||||
'ships-globe-outline',
|
||||
'ships-globe-lite',
|
||||
'ships-globe',
|
||||
'ships-globe-label',
|
||||
'ships-globe-hover-halo',
|
||||
@ -176,45 +183,14 @@ export function useProjectionToggle(
|
||||
|
||||
if (isTransition) setProjectionLoading(true);
|
||||
|
||||
const disposeMercatorOverlays = () => {
|
||||
const disposeOne = (target: MapboxOverlay | null, toNull: 'base' | 'interaction') => {
|
||||
if (!target) return;
|
||||
try {
|
||||
target.setProps({ layers: [] } as never);
|
||||
} catch {
|
||||
// ignore
|
||||
}
|
||||
try {
|
||||
map.removeControl(target as never);
|
||||
} catch {
|
||||
// ignore
|
||||
}
|
||||
try {
|
||||
target.finalize();
|
||||
} catch {
|
||||
// ignore
|
||||
}
|
||||
if (toNull === 'base') {
|
||||
overlayRef.current = null;
|
||||
} else {
|
||||
overlayInteractionRef.current = null;
|
||||
}
|
||||
};
|
||||
|
||||
disposeOne(overlayRef.current, 'base');
|
||||
disposeOne(overlayInteractionRef.current, 'interaction');
|
||||
// 파괴하지 않고 레이어만 비움 — 양쪽 파이프라인 항상 유지
|
||||
const quietMercatorOverlays = () => {
|
||||
try { overlayRef.current?.setProps({ layers: [] } as never); } catch { /* ignore */ }
|
||||
try { overlayInteractionRef.current?.setProps({ layers: [] } as never); } catch { /* ignore */ }
|
||||
};
|
||||
|
||||
const disposeGlobeDeckLayer = () => {
|
||||
const current = globeDeckLayerRef.current;
|
||||
if (!current) return;
|
||||
removeLayerIfExists(map, current.id);
|
||||
try {
|
||||
current.requestFinalize();
|
||||
} catch {
|
||||
// ignore
|
||||
}
|
||||
globeDeckLayerRef.current = null;
|
||||
const quietGlobeDeckLayer = () => {
|
||||
try { globeDeckLayerRef.current?.setProps({ layers: [] } as never); } catch { /* ignore */ }
|
||||
};
|
||||
|
||||
const syncProjectionAndDeck = () => {
|
||||
@ -236,11 +212,9 @@ export function useProjectionToggle(
|
||||
const shouldSwitchProjection = currentProjection !== next;
|
||||
|
||||
if (projection === 'globe') {
|
||||
disposeMercatorOverlays();
|
||||
clearGlobeNativeLayers();
|
||||
quietMercatorOverlays();
|
||||
} else {
|
||||
disposeGlobeDeckLayer();
|
||||
clearGlobeNativeLayers();
|
||||
quietGlobeDeckLayer();
|
||||
}
|
||||
|
||||
try {
|
||||
@ -248,6 +222,17 @@ export function useProjectionToggle(
|
||||
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());
|
||||
@ -263,17 +248,9 @@ export function useProjectionToggle(
|
||||
console.warn('Projection switch failed:', e);
|
||||
}
|
||||
|
||||
// 양쪽 overlay가 항상 존재하므로 재생성 불필요
|
||||
// deck-globe가 map에서 빠져있을 경우에만 재추가
|
||||
if (projection === 'globe') {
|
||||
disposeGlobeDeckLayer();
|
||||
|
||||
if (!globeDeckLayerRef.current) {
|
||||
globeDeckLayerRef.current = new MaplibreDeckCustomLayer({
|
||||
id: 'deck-globe',
|
||||
viewId: DECK_VIEW_ID,
|
||||
deckProps: { layers: [] },
|
||||
});
|
||||
}
|
||||
|
||||
const layer = globeDeckLayerRef.current;
|
||||
const layerId = layer?.id;
|
||||
if (layer && layerId && map.isStyleLoaded() && !map.getLayer(layerId)) {
|
||||
@ -282,14 +259,8 @@ export function useProjectionToggle(
|
||||
} catch {
|
||||
// ignore
|
||||
}
|
||||
if (!map.getLayer(layerId) && !cancelled && retries < maxRetries) {
|
||||
retries += 1;
|
||||
window.requestAnimationFrame(() => syncProjectionAndDeck());
|
||||
return;
|
||||
}
|
||||
}
|
||||
} else {
|
||||
disposeGlobeDeckLayer();
|
||||
ensureMercatorOverlay();
|
||||
}
|
||||
|
||||
@ -324,7 +295,7 @@ export function useProjectionToggle(
|
||||
if (settleCleanup) settleCleanup();
|
||||
if (isTransition) setProjectionLoading(false);
|
||||
};
|
||||
}, [projection, clearGlobeNativeLayers, ensureMercatorOverlay, reorderGlobeFeatureLayers, setProjectionLoading]);
|
||||
}, [projection, ensureMercatorOverlay, reorderGlobeFeatureLayers, setProjectionLoading]);
|
||||
|
||||
return reorderGlobeFeatureLayers;
|
||||
}
|
||||
|
||||
@ -16,6 +16,7 @@ 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];
|
||||
@ -63,10 +64,10 @@ const LAYER_SPECS: NativeLayerSpec[] = [
|
||||
type: 'line',
|
||||
sourceId: SRC_ID,
|
||||
paint: {
|
||||
'line-color': ['get', 'color'],
|
||||
'line-color': '#ffffff',
|
||||
'line-opacity': 0,
|
||||
'line-width': ['interpolate', ['linear'], ['zoom'], 2, 5, 6, 8, 10, 12],
|
||||
'line-blur': ['interpolate', ['linear'], ['zoom'], 2, 3, 6, 5, 10, 7],
|
||||
'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' },
|
||||
@ -107,6 +108,29 @@ const LAYER_SPECS: NativeLayerSpec[] = [
|
||||
},
|
||||
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(
|
||||
@ -250,42 +274,27 @@ export function useSubcablesLayer(
|
||||
}
|
||||
|
||||
/* ── 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(LINE_ID)) {
|
||||
map.setPaintProperty(LINE_ID, 'line-opacity', ['case', matchExpr, 1.0, 0.2] as never);
|
||||
map.setPaintProperty(LINE_ID, 'line-width', ['case', matchExpr, 4.5, 1.2] as never);
|
||||
}
|
||||
if (map.getLayer(CASING_ID)) {
|
||||
map.setPaintProperty(CASING_ID, 'line-opacity', ['case', matchExpr, 0.7, 0.12] as never);
|
||||
map.setPaintProperty(CASING_ID, 'line-width', ['case', matchExpr, 6.5, 2.0] as never);
|
||||
}
|
||||
if (map.getLayer(GLOW_ID)) {
|
||||
map.setFilter(GLOW_ID, matchExpr as never);
|
||||
map.setPaintProperty(GLOW_ID, 'line-opacity', 0.35);
|
||||
map.setPaintProperty(GLOW_ID, 'line-opacity', 0.55);
|
||||
}
|
||||
if (map.getLayer(POINTS_ID)) {
|
||||
map.setPaintProperty(POINTS_ID, 'circle-opacity', ['case', matchExpr, 1.0, 0.15] as never);
|
||||
map.setPaintProperty(POINTS_ID, 'circle-radius', ['case', matchExpr, 4, 1.5] as never);
|
||||
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(LINE_ID)) {
|
||||
map.setPaintProperty(LINE_ID, 'line-opacity', LINE_OPACITY_DEFAULT as never);
|
||||
map.setPaintProperty(LINE_ID, 'line-width', LINE_WIDTH_DEFAULT as never);
|
||||
}
|
||||
if (map.getLayer(CASING_ID)) {
|
||||
map.setPaintProperty(CASING_ID, 'line-opacity', CASING_OPACITY_DEFAULT as never);
|
||||
map.setPaintProperty(CASING_ID, 'line-width', CASING_WIDTH_DEFAULT as never);
|
||||
}
|
||||
if (map.getLayer(GLOW_ID)) {
|
||||
map.setFilter(GLOW_ID, ['==', ['get', 'id'], ''] as never);
|
||||
map.setFilter(GLOW_ID, noMatch);
|
||||
map.setPaintProperty(GLOW_ID, 'line-opacity', 0);
|
||||
}
|
||||
if (map.getLayer(POINTS_ID)) {
|
||||
map.setPaintProperty(POINTS_ID, 'circle-opacity', POINTS_OPACITY_DEFAULT as never);
|
||||
map.setPaintProperty(POINTS_ID, 'circle-radius', POINTS_RADIUS_DEFAULT as never);
|
||||
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]);
|
||||
}
|
||||
@ -1,4 +1,4 @@
|
||||
import { useEffect, type MutableRefObject } from 'react';
|
||||
import { useEffect, useMemo, type MutableRefObject } from 'react';
|
||||
import maplibregl, {
|
||||
type GeoJSONSource,
|
||||
type GeoJSONSourceSpecification,
|
||||
@ -10,6 +10,34 @@ 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>,
|
||||
@ -26,6 +54,12 @@ export function useZonesLayer(
|
||||
) {
|
||||
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;
|
||||
@ -47,33 +81,31 @@ export function useZonesLayer(
|
||||
zoneLabelExpr.push(['coalesce', ['get', 'zoneName'], ['get', 'zoneLabel'], ['get', 'NAME'], '수역']);
|
||||
|
||||
const ensure = () => {
|
||||
if (projectionBusyRef.current) return;
|
||||
const visibility = overlays.zones ? 'visible' : 'none';
|
||||
try {
|
||||
if (map.getLayer(fillId)) map.setLayoutProperty(fillId, 'visibility', visibility);
|
||||
} catch {
|
||||
// ignore
|
||||
}
|
||||
try {
|
||||
if (map.getLayer(lineId)) map.setLayoutProperty(lineId, 'visibility', visibility);
|
||||
} catch {
|
||||
// ignore
|
||||
}
|
||||
try {
|
||||
if (map.getLayer(labelId)) map.setLayoutProperty(labelId, 'visibility', visibility);
|
||||
} catch {
|
||||
// ignore
|
||||
// 소스 데이터 간소화 — 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 {
|
||||
const existing = map.getSource(srcId) as GeoJSONSource | undefined;
|
||||
if (existing) {
|
||||
existing.setData(zones);
|
||||
} else {
|
||||
map.addSource(srcId, { type: 'geojson', data: zones } as GeoJSONSourceSpecification);
|
||||
// 소스가 아직 없으면 생성 (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();
|
||||
@ -226,5 +258,5 @@ export function useZonesLayer(
|
||||
return () => {
|
||||
stop();
|
||||
};
|
||||
}, [zones, overlays.zones, projection, baseMap, hoveredZoneId, mapSyncEpoch, reorderGlobeFeatureLayers]);
|
||||
}, [zones, simplifiedZones, overlays.zones, projection, baseMap, hoveredZoneId, mapSyncEpoch, reorderGlobeFeatureLayers]);
|
||||
}
|
||||
|
||||
@ -11,10 +11,53 @@ export const SHALLOW_WATER_LINE_DEFAULT = '#114f5c';
|
||||
|
||||
const BATHY_ZOOM_RANGES: BathyZoomRange[] = [
|
||||
{ id: 'bathymetry-fill', mercator: [3, 24], globe: [3, 24] },
|
||||
{ id: 'bathymetry-borders', mercator: [5, 24], globe: [5, 24] },
|
||||
{ id: 'bathymetry-borders-major', 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';
|
||||
|
||||
@ -31,17 +74,13 @@ export function injectOceanBathymetryLayers(style: StyleSpecification, maptilerK
|
||||
const depth = ['to-number', ['get', 'depth']] as unknown as number[];
|
||||
const depthLabel = ['concat', ['to-string', ['*', depth, -1]], 'm'] as unknown as string[];
|
||||
|
||||
// Bug #3 fix: shallow depths now use brighter teal tones to distinguish from deep ocean
|
||||
// 수심 색상: 전체 범위 (-8000m ~ 0m)
|
||||
const bathyFillColor = [
|
||||
'interpolate',
|
||||
['linear'],
|
||||
depth,
|
||||
-11000,
|
||||
'#00040b',
|
||||
-8000,
|
||||
'#010610',
|
||||
-6000,
|
||||
'#020816',
|
||||
-4000,
|
||||
'#030c1c',
|
||||
-2000,
|
||||
@ -64,6 +103,15 @@ export function injectOceanBathymetryLayers(style: StyleSpecification, maptilerK
|
||||
'#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',
|
||||
@ -77,98 +125,132 @@ export function injectOceanBathymetryLayers(style: StyleSpecification, maptilerK
|
||||
},
|
||||
} as unknown as LayerSpecification;
|
||||
|
||||
const bathyBandBorders: LayerSpecification = {
|
||||
// === Borders (contour polygon edges) — 2-tier LOD ===
|
||||
// z3-z7: 1000m, 2000m 경계만
|
||||
const bathyBordersMajor: LayerSpecification = {
|
||||
id: 'bathymetry-borders-major',
|
||||
type: 'line',
|
||||
source: oceanSourceId,
|
||||
'source-layer': 'contour',
|
||||
minzoom: 3,
|
||||
maxzoom: 7,
|
||||
filter: depthIn(DEPTHS_COARSE) as unknown as unknown[],
|
||||
paint: {
|
||||
'line-color': 'rgba(255,255,255,0.14)',
|
||||
'line-opacity': ['interpolate', ['linear'], ['zoom'], 3, 0.10, 6, 0.16],
|
||||
'line-blur': ['interpolate', ['linear'], ['zoom'], 3, 0.5, 6, 0.35],
|
||||
'line-width': ['interpolate', ['linear'], ['zoom'], 3, 0.25, 6, 0.4],
|
||||
},
|
||||
} as unknown as LayerSpecification;
|
||||
|
||||
// z7+: 전체 등심선 경계
|
||||
const bathyBorders: LayerSpecification = {
|
||||
id: 'bathymetry-borders',
|
||||
type: 'line',
|
||||
source: oceanSourceId,
|
||||
'source-layer': 'contour',
|
||||
minzoom: 5, // fill은 3부터, borders는 5부터
|
||||
minzoom: 7,
|
||||
maxzoom: 24,
|
||||
paint: {
|
||||
'line-color': 'rgba(255,255,255,0.06)',
|
||||
'line-opacity': ['interpolate', ['linear'], ['zoom'], 4, 0.12, 8, 0.18, 12, 0.22],
|
||||
'line-blur': ['interpolate', ['linear'], ['zoom'], 4, 0.8, 10, 0.2],
|
||||
'line-width': ['interpolate', ['linear'], ['zoom'], 4, 0.2, 8, 0.35, 12, 0.6],
|
||||
'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;
|
||||
|
||||
const bathyLinesMinor: LayerSpecification = {
|
||||
id: 'bathymetry-lines',
|
||||
// === 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: 7,
|
||||
minzoom: 5,
|
||||
maxzoom: 7,
|
||||
filter: depthIn(DEPTHS_COARSE) as unknown as unknown[],
|
||||
paint: {
|
||||
'line-color': [
|
||||
'interpolate',
|
||||
['linear'],
|
||||
depth,
|
||||
-11000,
|
||||
'rgba(255,255,255,0.04)',
|
||||
-6000,
|
||||
'rgba(255,255,255,0.05)',
|
||||
-2000,
|
||||
'rgba(255,255,255,0.07)',
|
||||
0,
|
||||
'rgba(255,255,255,0.10)',
|
||||
],
|
||||
'line-opacity': ['interpolate', ['linear'], ['zoom'], 8, 0.18, 10, 0.22, 12, 0.28],
|
||||
'line-blur': ['interpolate', ['linear'], ['zoom'], 8, 0.8, 11, 0.3],
|
||||
'line-width': ['interpolate', ['linear'], ['zoom'], 8, 0.35, 10, 0.55, 12, 0.85],
|
||||
'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;
|
||||
|
||||
const majorDepths = [-50, -100, -200, -500, -1000, -2000, -4000, -6000, -8000, -9500];
|
||||
const bathyMajorDepthFilter: unknown[] = [
|
||||
'in',
|
||||
['to-number', ['get', 'depth']],
|
||||
['literal', majorDepths],
|
||||
] as unknown[];
|
||||
|
||||
// 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: 24,
|
||||
filter: bathyMajorDepthFilter as unknown as unknown[],
|
||||
maxzoom: 9,
|
||||
filter: depthIn(DEPTHS_MEDIUM) as unknown as unknown[],
|
||||
paint: {
|
||||
'line-color': 'rgba(255,255,255,0.16)',
|
||||
'line-opacity': ['interpolate', ['linear'], ['zoom'], 8, 0.22, 10, 0.28, 12, 0.34],
|
||||
'line-blur': ['interpolate', ['linear'], ['zoom'], 8, 0.4, 11, 0.2],
|
||||
'line-width': ['interpolate', ['linear'], ['zoom'], 8, 0.6, 10, 0.95, 12, 1.3],
|
||||
'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;
|
||||
|
||||
const bathyBandBordersMajor: LayerSpecification = {
|
||||
id: 'bathymetry-borders-major',
|
||||
// z9+: 50~8000m (풀 디테일)
|
||||
const bathyLinesDetail: LayerSpecification = {
|
||||
id: 'bathymetry-lines-detail',
|
||||
type: 'line',
|
||||
source: oceanSourceId,
|
||||
'source-layer': 'contour',
|
||||
minzoom: 3,
|
||||
'source-layer': 'contour_line',
|
||||
minzoom: 9,
|
||||
maxzoom: 24,
|
||||
filter: bathyMajorDepthFilter as unknown as unknown[],
|
||||
filter: depthIn(DEPTHS_DETAIL) as unknown as unknown[],
|
||||
paint: {
|
||||
'line-color': 'rgba(255,255,255,0.14)',
|
||||
'line-opacity': ['interpolate', ['linear'], ['zoom'], 3, 0.10, 6, 0.16, 8, 0.2, 12, 0.26],
|
||||
'line-blur': ['interpolate', ['linear'], ['zoom'], 3, 0.5, 6, 0.35, 10, 0.15],
|
||||
'line-width': ['interpolate', ['linear'], ['zoom'], 3, 0.25, 6, 0.4, 8, 0.55, 12, 0.85],
|
||||
'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: 7,
|
||||
filter: bathyMajorDepthFilter as unknown as unknown[],
|
||||
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'], 7, 10, 9, 12, 11, 14, 13, 16],
|
||||
'text-size': ['interpolate', ['linear'], ['zoom'], 9, 12, 11, 14, 13, 16],
|
||||
'text-allow-overlap': false,
|
||||
'text-padding': 4,
|
||||
'text-rotation-alignment': 'map',
|
||||
@ -244,10 +326,12 @@ export function injectOceanBathymetryLayers(style: StyleSpecification, maptilerK
|
||||
|
||||
const toInsert = [
|
||||
bathyFill,
|
||||
bathyBandBorders,
|
||||
bathyBandBordersMajor,
|
||||
bathyLinesMinor,
|
||||
bathyBordersMajor,
|
||||
bathyBorders,
|
||||
bathyLinesCoarse,
|
||||
bathyLinesMajor,
|
||||
bathyLinesDetail,
|
||||
bathyLabelsCoarse,
|
||||
bathyLabels,
|
||||
landformLabels,
|
||||
].filter((l) => !existingIds.has(l.id));
|
||||
@ -273,6 +357,7 @@ export function applyBathymetryZoomProfile(map: maplibregl.Map, baseMap: BaseMap
|
||||
// ignore
|
||||
}
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
function applyKoreanLabels(style: StyleSpecification) {
|
||||
@ -298,6 +383,7 @@ export async function resolveInitialMapStyle(signal: AbortSignal): Promise<strin
|
||||
const res = await fetch(styleUrl, { signal, headers: { accept: 'application/json' } });
|
||||
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;
|
||||
|
||||
485
apps/web/src/widgets/map3d/lib/deckLayerFactories.ts
Normal file
485
apps/web/src/widgets/map3d/lib/deckLayerFactories.ts
Normal file
@ -0,0 +1,485 @@
|
||||
import { HexagonLayer } from '@deck.gl/aggregation-layers';
|
||||
import { IconLayer, LineLayer, ScatterplotLayer } from '@deck.gl/layers';
|
||||
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 { FleetCircle, PairLink } from '../../../features/legacyDashboard/model/types';
|
||||
import type { MapToggleState } from '../../../features/mapToggles/MapToggles';
|
||||
import type { DashSeg, PairRangeCircle } from '../types';
|
||||
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 { getDisplayHeading, getShipColor } from './shipUtils';
|
||||
import { getCachedShipIcon } from './shipIconCache';
|
||||
|
||||
/* ── 공통 콜백 인터페이스 ─────────────────────────────── */
|
||||
|
||||
interface DeckHoverCallbacks {
|
||||
touchDeckHoverState: (isHover: boolean) => void;
|
||||
setDeckHoverPairs: (next: number[]) => void;
|
||||
setDeckHoverMmsi: (next: number[]) => void;
|
||||
clearDeckHoverPairs: () => void;
|
||||
clearMapFleetHoverState: () => void;
|
||||
setMapFleetHoverState: (ownerKey: string | null, vesselMmsis: number[]) => void;
|
||||
toFleetMmsiList: (value: unknown) => number[];
|
||||
}
|
||||
|
||||
interface DeckSelectCallbacks {
|
||||
hasAuxiliarySelectModifier: (ev?: { shiftKey?: boolean; ctrlKey?: boolean; metaKey?: boolean } | null) => boolean;
|
||||
onSelectMmsi: (mmsi: number | null) => void;
|
||||
onToggleHighlightMmsi?: (mmsi: number) => void;
|
||||
onDeckSelectOrHighlight: (info: unknown, allowMultiSelect?: boolean) => void;
|
||||
}
|
||||
|
||||
/* ── Mercator Deck 레이어 ─────────────────────────────── */
|
||||
|
||||
export interface MercatorDeckLayerContext extends DeckHoverCallbacks, DeckSelectCallbacks {
|
||||
shipLayerData: AisTarget[];
|
||||
shipOverlayLayerData: AisTarget[];
|
||||
legacyTargetsOrdered: AisTarget[];
|
||||
legacyOverlayTargets: AisTarget[];
|
||||
legacyHits: Map<number, LegacyVesselInfo> | null | undefined;
|
||||
pairLinks: PairLink[] | undefined;
|
||||
fcDashed: DashSeg[];
|
||||
fleetCircles: FleetCircle[] | undefined;
|
||||
pairRanges: PairRangeCircle[];
|
||||
pairLinksInteractive: PairLink[];
|
||||
pairRangesInteractive: PairRangeCircle[];
|
||||
fcLinesInteractive: DashSeg[];
|
||||
fleetCirclesInteractive: FleetCircle[];
|
||||
overlays: MapToggleState;
|
||||
showDensity: boolean;
|
||||
showShips: boolean;
|
||||
selectedMmsi: number | null;
|
||||
shipHighlightSet: Set<number>;
|
||||
}
|
||||
|
||||
export function buildMercatorDeckLayers(ctx: MercatorDeckLayerContext): unknown[] {
|
||||
const layers: unknown[] = [];
|
||||
const overlayParams = DEPTH_DISABLED_PARAMS;
|
||||
const clearDeckHover = () => { ctx.touchDeckHoverState(false); };
|
||||
const isTargetShip = (mmsi: number) => (ctx.legacyHits ? ctx.legacyHits.has(mmsi) : false);
|
||||
|
||||
const shipOtherData: AisTarget[] = [];
|
||||
const shipTargetData: AisTarget[] = [];
|
||||
for (const t of ctx.shipLayerData) {
|
||||
if (isTargetShip(t.mmsi)) shipTargetData.push(t);
|
||||
else shipOtherData.push(t);
|
||||
}
|
||||
const shipOverlayOtherData: AisTarget[] = [];
|
||||
for (const t of ctx.shipOverlayLayerData) {
|
||||
if (!isTargetShip(t.mmsi)) shipOverlayOtherData.push(t);
|
||||
}
|
||||
|
||||
/* ─ density ─ */
|
||||
if (ctx.showDensity) {
|
||||
layers.push(
|
||||
new HexagonLayer<AisTarget>({
|
||||
id: 'density',
|
||||
data: ctx.shipLayerData,
|
||||
pickable: true,
|
||||
extruded: true,
|
||||
radius: 2500,
|
||||
elevationScale: 35,
|
||||
coverage: 0.92,
|
||||
opacity: 0.35,
|
||||
getPosition: (d) => [d.lon, d.lat],
|
||||
}),
|
||||
);
|
||||
}
|
||||
|
||||
/* ─ pair range ─ */
|
||||
if (ctx.overlays.pairRange && ctx.pairRanges.length > 0) {
|
||||
layers.push(
|
||||
new ScatterplotLayer<PairRangeCircle>({
|
||||
id: 'pair-range',
|
||||
data: ctx.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; }
|
||||
ctx.touchDeckHoverState(true);
|
||||
const p = info.object as PairRangeCircle;
|
||||
ctx.setDeckHoverPairs([p.aMmsi, p.bMmsi]);
|
||||
ctx.setDeckHoverMmsi([p.aMmsi, p.bMmsi]);
|
||||
ctx.clearMapFleetHoverState();
|
||||
},
|
||||
onClick: (info) => {
|
||||
if (!info.object) { ctx.onSelectMmsi(null); return; }
|
||||
const obj = info.object as PairRangeCircle;
|
||||
const sourceEvent = (info as { srcEvent?: { shiftKey?: boolean; ctrlKey?: boolean; metaKey?: boolean } | null }).srcEvent;
|
||||
if (sourceEvent && ctx.hasAuxiliarySelectModifier(sourceEvent)) {
|
||||
ctx.onToggleHighlightMmsi?.(obj.aMmsi);
|
||||
ctx.onToggleHighlightMmsi?.(obj.bMmsi);
|
||||
return;
|
||||
}
|
||||
ctx.onDeckSelectOrHighlight({ mmsi: obj.aMmsi });
|
||||
},
|
||||
}),
|
||||
);
|
||||
}
|
||||
|
||||
/* ─ pair lines ─ */
|
||||
if (ctx.overlays.pairLines && (ctx.pairLinks?.length ?? 0) > 0) {
|
||||
layers.push(
|
||||
new LineLayer<PairLink>({
|
||||
id: 'pair-lines',
|
||||
data: ctx.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; }
|
||||
ctx.touchDeckHoverState(true);
|
||||
const obj = info.object as PairLink;
|
||||
ctx.setDeckHoverPairs([obj.aMmsi, obj.bMmsi]);
|
||||
ctx.setDeckHoverMmsi([obj.aMmsi, obj.bMmsi]);
|
||||
ctx.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 && ctx.hasAuxiliarySelectModifier(sourceEvent)) {
|
||||
ctx.onToggleHighlightMmsi?.(obj.aMmsi);
|
||||
ctx.onToggleHighlightMmsi?.(obj.bMmsi);
|
||||
return;
|
||||
}
|
||||
ctx.onDeckSelectOrHighlight({ mmsi: obj.aMmsi });
|
||||
},
|
||||
}),
|
||||
);
|
||||
}
|
||||
|
||||
/* ─ fc lines ─ */
|
||||
if (ctx.overlays.fcLines && ctx.fcDashed.length > 0) {
|
||||
layers.push(
|
||||
new LineLayer<DashSeg>({
|
||||
id: 'fc-lines',
|
||||
data: ctx.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; }
|
||||
ctx.touchDeckHoverState(true);
|
||||
const obj = info.object as DashSeg;
|
||||
if (obj.fromMmsi == null || obj.toMmsi == null) { clearDeckHover(); return; }
|
||||
ctx.setDeckHoverPairs([obj.fromMmsi, obj.toMmsi]);
|
||||
ctx.setDeckHoverMmsi([obj.fromMmsi, obj.toMmsi]);
|
||||
ctx.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 && ctx.hasAuxiliarySelectModifier(sourceEvent)) {
|
||||
ctx.onToggleHighlightMmsi?.(obj.fromMmsi);
|
||||
ctx.onToggleHighlightMmsi?.(obj.toMmsi);
|
||||
return;
|
||||
}
|
||||
ctx.onDeckSelectOrHighlight({ mmsi: obj.fromMmsi });
|
||||
},
|
||||
}),
|
||||
);
|
||||
}
|
||||
|
||||
/* ─ fleet circles ─ */
|
||||
if (ctx.overlays.fleetCircles && (ctx.fleetCircles?.length ?? 0) > 0) {
|
||||
layers.push(
|
||||
new ScatterplotLayer<FleetCircle>({
|
||||
id: 'fleet-circles',
|
||||
data: ctx.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; }
|
||||
ctx.touchDeckHoverState(true);
|
||||
const obj = info.object as FleetCircle;
|
||||
const list = ctx.toFleetMmsiList(obj.vesselMmsis);
|
||||
ctx.setMapFleetHoverState(obj.ownerKey || null, list);
|
||||
ctx.setDeckHoverMmsi(list);
|
||||
ctx.clearDeckHoverPairs();
|
||||
},
|
||||
onClick: (info) => {
|
||||
if (!info.object) return;
|
||||
const obj = info.object as FleetCircle;
|
||||
const list = ctx.toFleetMmsiList(obj.vesselMmsis);
|
||||
const sourceEvent = (info as { srcEvent?: { shiftKey?: boolean; ctrlKey?: boolean; metaKey?: boolean } | null }).srcEvent;
|
||||
if (sourceEvent && ctx.hasAuxiliarySelectModifier(sourceEvent)) {
|
||||
for (const mmsi of list) ctx.onToggleHighlightMmsi?.(mmsi);
|
||||
return;
|
||||
}
|
||||
const first = list[0];
|
||||
if (first != null) ctx.onDeckSelectOrHighlight({ mmsi: first });
|
||||
},
|
||||
}),
|
||||
);
|
||||
layers.push(
|
||||
new ScatterplotLayer<FleetCircle>({
|
||||
id: 'fleet-circles-fill',
|
||||
data: ctx.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,
|
||||
}),
|
||||
);
|
||||
}
|
||||
|
||||
/* ─ ships ─ */
|
||||
if (ctx.showShips) {
|
||||
const shipOnHover = (info: PickingInfo) => {
|
||||
if (!info.object) { clearDeckHover(); return; }
|
||||
ctx.touchDeckHoverState(true);
|
||||
const obj = info.object as AisTarget;
|
||||
ctx.setDeckHoverMmsi([obj.mmsi]);
|
||||
ctx.clearDeckHoverPairs();
|
||||
ctx.clearMapFleetHoverState();
|
||||
};
|
||||
const shipOnClick = (info: PickingInfo) => {
|
||||
if (!info.object) { ctx.onSelectMmsi(null); return; }
|
||||
ctx.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 (ctx.selectedMmsi != null && d.mmsi === ctx.selectedMmsi) return FLAT_SHIP_ICON_SIZE_SELECTED;
|
||||
if (ctx.shipHighlightSet.has(d.mmsi)) return FLAT_SHIP_ICON_SIZE_HIGHLIGHTED;
|
||||
return 0;
|
||||
},
|
||||
getColor: (d) => getShipColor(d, ctx.selectedMmsi, null, ctx.shipHighlightSet),
|
||||
alphaCutoff: 0.05,
|
||||
}),
|
||||
);
|
||||
}
|
||||
|
||||
if (ctx.legacyTargetsOrdered.length > 0) {
|
||||
layers.push(
|
||||
new ScatterplotLayer<AisTarget>({
|
||||
id: 'legacy-halo',
|
||||
data: ctx.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, ctx.legacyHits?.get(d.mmsi)?.shipCode ?? null, EMPTY_MMSI_SET),
|
||||
onHover: shipOnHover,
|
||||
onClick: shipOnClick,
|
||||
alphaCutoff: 0.05,
|
||||
}),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/* ─ interactive overlays ─ */
|
||||
if (ctx.pairRangesInteractive.length > 0) {
|
||||
layers.push(new ScatterplotLayer<PairRangeCircle>({ id: 'pair-range-overlay', data: ctx.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 (ctx.pairLinksInteractive.length > 0) {
|
||||
layers.push(new LineLayer<PairLink>({ id: 'pair-lines-overlay', data: ctx.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 (ctx.fcLinesInteractive.length > 0) {
|
||||
layers.push(new LineLayer<DashSeg>({ id: 'fc-lines-overlay', data: ctx.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 (ctx.fleetCirclesInteractive.length > 0) {
|
||||
layers.push(new ScatterplotLayer<FleetCircle>({ id: 'fleet-circles-overlay-fill', data: ctx.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: ctx.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 }));
|
||||
}
|
||||
|
||||
/* ─ legacy overlay (highlight/selected) ─ */
|
||||
if (ctx.showShips && ctx.legacyOverlayTargets.length > 0) {
|
||||
layers.push(new ScatterplotLayer<AisTarget>({ id: 'legacy-halo-overlay', data: ctx.legacyOverlayTargets, pickable: false, billboard: false, parameters: overlayParams, filled: false, stroked: true, radiusUnits: 'pixels', getRadius: (d) => { if (ctx.selectedMmsi && d.mmsi === ctx.selectedMmsi) return FLAT_LEGACY_HALO_RADIUS_SELECTED; return FLAT_LEGACY_HALO_RADIUS_HIGHLIGHTED; }, lineWidthUnits: 'pixels', getLineWidth: (d) => (ctx.selectedMmsi && d.mmsi === ctx.selectedMmsi ? 2.5 : 2.2), getLineColor: (d) => { if (ctx.selectedMmsi && d.mmsi === ctx.selectedMmsi) return HALO_OUTLINE_COLOR_SELECTED; if (ctx.shipHighlightSet.has(d.mmsi)) return HALO_OUTLINE_COLOR_HIGHLIGHTED; return HALO_OUTLINE_COLOR; }, getPosition: (d) => [d.lon, d.lat] as [number, number] }));
|
||||
}
|
||||
|
||||
if (ctx.showShips && ctx.shipOverlayLayerData.filter((t) => ctx.legacyHits?.has(t.mmsi)).length > 0) {
|
||||
const shipOverlayTargetData2 = ctx.shipOverlayLayerData.filter((t) => ctx.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 (ctx.selectedMmsi != null && d.mmsi === ctx.selectedMmsi) return FLAT_SHIP_ICON_SIZE_SELECTED; if (ctx.shipHighlightSet.has(d.mmsi)) return FLAT_SHIP_ICON_SIZE_HIGHLIGHTED; return 0; }, getColor: (d) => { if (!ctx.shipHighlightSet.has(d.mmsi) && !(ctx.selectedMmsi != null && d.mmsi === ctx.selectedMmsi)) return [0, 0, 0, 0]; return getShipColor(d, ctx.selectedMmsi, ctx.legacyHits?.get(d.mmsi)?.shipCode ?? null, ctx.shipHighlightSet); } }));
|
||||
}
|
||||
|
||||
return layers;
|
||||
}
|
||||
|
||||
/* ── Globe Deck 오버레이 레이어 ────────────────────────── */
|
||||
|
||||
export interface GlobeDeckLayerContext {
|
||||
pairRanges: PairRangeCircle[];
|
||||
pairLinks: PairLink[] | undefined;
|
||||
fcDashed: DashSeg[];
|
||||
fleetCircles: FleetCircle[] | undefined;
|
||||
legacyTargetsOrdered: AisTarget[];
|
||||
legacyHits: Map<number, LegacyVesselInfo> | null | undefined;
|
||||
overlays: MapToggleState;
|
||||
showShips: boolean;
|
||||
selectedMmsi: number | null;
|
||||
isHighlightedFleet: (ownerKey: string, vesselMmsis: number[]) => boolean;
|
||||
isHighlightedPair: (aMmsi: number, bMmsi: number) => boolean;
|
||||
isHighlightedMmsi: (mmsi: number) => boolean;
|
||||
touchDeckHoverState: (isHover: boolean) => void;
|
||||
setDeckHoverPairs: (next: number[]) => void;
|
||||
setDeckHoverMmsi: (next: number[]) => void;
|
||||
clearDeckHoverPairs: () => void;
|
||||
clearDeckHoverMmsi: () => void;
|
||||
clearMapFleetHoverState: () => void;
|
||||
setMapFleetHoverState: (ownerKey: string | null, vesselMmsis: number[]) => void;
|
||||
toFleetMmsiList: (value: unknown) => number[];
|
||||
}
|
||||
|
||||
export function buildGlobeDeckLayers(ctx: GlobeDeckLayerContext): unknown[] {
|
||||
const overlayParams = GLOBE_OVERLAY_PARAMS;
|
||||
const globeLayers: unknown[] = [];
|
||||
|
||||
if (ctx.overlays.pairRange && ctx.pairRanges.length > 0) {
|
||||
globeLayers.push(new ScatterplotLayer<PairRangeCircle>({ id: 'pair-range-globe', data: ctx.pairRanges, pickable: true, billboard: false, parameters: overlayParams, filled: false, stroked: true, radiusUnits: 'meters', getRadius: (d) => d.radiusNm * 1852, radiusMinPixels: 10, lineWidthUnits: 'pixels', getLineWidth: (d) => (ctx.isHighlightedPair(d.aMmsi, d.bMmsi) ? 2.2 : 1), getLineColor: (d) => { const hl = ctx.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) { ctx.clearDeckHoverPairs(); ctx.clearDeckHoverMmsi(); return; } ctx.touchDeckHoverState(true); const p = info.object as PairRangeCircle; ctx.setDeckHoverPairs([p.aMmsi, p.bMmsi]); ctx.setDeckHoverMmsi([p.aMmsi, p.bMmsi]); ctx.clearMapFleetHoverState(); } }));
|
||||
}
|
||||
|
||||
if (ctx.overlays.pairLines && (ctx.pairLinks?.length ?? 0) > 0) {
|
||||
const links = ctx.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 = ctx.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) => (ctx.isHighlightedPair(d.aMmsi, d.bMmsi) ? 2.6 : d.warn ? 2.2 : 1.4), widthUnits: 'pixels', onHover: (info) => { if (!info.object) { ctx.clearDeckHoverPairs(); ctx.clearDeckHoverMmsi(); return; } ctx.touchDeckHoverState(true); const obj = info.object as PairLink; ctx.setDeckHoverPairs([obj.aMmsi, obj.bMmsi]); ctx.setDeckHoverMmsi([obj.aMmsi, obj.bMmsi]); ctx.clearMapFleetHoverState(); } }));
|
||||
}
|
||||
|
||||
if (ctx.overlays.fcLines && ctx.fcDashed.length > 0) {
|
||||
globeLayers.push(new LineLayer<DashSeg>({ id: 'fc-lines-globe', data: ctx.fcDashed, pickable: true, parameters: overlayParams, getSourcePosition: (d) => d.from, getTargetPosition: (d) => d.to, getColor: (d) => { const ih = [d.fromMmsi, d.toMmsi].some((v) => ctx.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) => ctx.isHighlightedMmsi(v ?? -1)); return ih ? 1.9 : 1.3; }, widthUnits: 'pixels', onHover: (info) => { if (!info.object) { ctx.clearDeckHoverPairs(); ctx.clearDeckHoverMmsi(); return; } ctx.touchDeckHoverState(true); const obj = info.object as DashSeg; if (obj.fromMmsi == null || obj.toMmsi == null) { ctx.clearDeckHoverPairs(); ctx.clearDeckHoverMmsi(); return; } ctx.setDeckHoverPairs([obj.fromMmsi, obj.toMmsi]); ctx.setDeckHoverMmsi([obj.fromMmsi, obj.toMmsi]); ctx.clearMapFleetHoverState(); } }));
|
||||
}
|
||||
|
||||
if (ctx.overlays.fleetCircles && (ctx.fleetCircles?.length ?? 0) > 0) {
|
||||
const circles = ctx.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) => (ctx.isHighlightedFleet(d.ownerKey, d.vesselMmsis) ? 1.8 : 1.1), getLineColor: (d) => (ctx.isHighlightedFleet(d.ownerKey, d.vesselMmsis) ? FLEET_RANGE_LINE_DECK_HL : FLEET_RANGE_LINE_DECK), getPosition: (d) => d.center, onHover: (info) => { if (!info.object) { ctx.clearDeckHoverPairs(); ctx.clearDeckHoverMmsi(); ctx.clearMapFleetHoverState(); return; } ctx.touchDeckHoverState(true); const obj = info.object as FleetCircle; const list = ctx.toFleetMmsiList(obj.vesselMmsis); ctx.setMapFleetHoverState(obj.ownerKey || null, list); ctx.setDeckHoverMmsi(list); ctx.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) => (ctx.isHighlightedFleet(d.ownerKey, d.vesselMmsis) ? FLEET_RANGE_FILL_DECK_HL : FLEET_RANGE_FILL_DECK), getPosition: (d) => d.center }));
|
||||
}
|
||||
|
||||
if (ctx.showShips && ctx.legacyTargetsOrdered.length > 0) {
|
||||
globeLayers.push(new ScatterplotLayer<AisTarget>({ id: 'legacy-halo-globe', data: ctx.legacyTargetsOrdered, pickable: false, billboard: false, parameters: overlayParams, filled: false, stroked: true, radiusUnits: 'pixels', getRadius: (d) => { if (ctx.selectedMmsi && d.mmsi === ctx.selectedMmsi) return FLAT_LEGACY_HALO_RADIUS_SELECTED; return FLAT_LEGACY_HALO_RADIUS; }, lineWidthUnits: 'pixels', getLineWidth: (d) => (ctx.selectedMmsi && d.mmsi === ctx.selectedMmsi ? 2.5 : 2), getLineColor: (d) => { if (ctx.selectedMmsi && d.mmsi === ctx.selectedMmsi) return HALO_OUTLINE_COLOR_SELECTED; return HALO_OUTLINE_COLOR; }, getPosition: (d) => [d.lon, d.lat] as [number, number] }));
|
||||
}
|
||||
|
||||
return globeLayers;
|
||||
}
|
||||
@ -38,20 +38,19 @@ export function destinationPointLngLat(
|
||||
return [outLon, outLat];
|
||||
}
|
||||
|
||||
export function circleRingLngLat(center: [number, number], radiusMeters: number, steps = 72): [number, number][] {
|
||||
const [lon0, lat0] = center;
|
||||
const latRad = lat0 * DEG2RAD;
|
||||
const cosLat = Math.max(1e-6, Math.cos(latRad));
|
||||
const r = Math.max(0, radiusMeters);
|
||||
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 dy = r * Math.sin(a);
|
||||
const dx = r * Math.cos(a);
|
||||
const dLat = (dy / EARTH_RADIUS_M) / DEG2RAD;
|
||||
const dLon = (dx / (EARTH_RADIUS_M * cosLat)) / DEG2RAD;
|
||||
ring.push([lon0 + dLon, lat0 + dLat]);
|
||||
const pt = destinationPointLngLat(center, a * RAD2DEG, r);
|
||||
ring.push(pt);
|
||||
}
|
||||
// 고리 닫기 보정
|
||||
if (ring.length > 1) {
|
||||
ring[ring.length - 1] = ring[0];
|
||||
}
|
||||
return ring;
|
||||
}
|
||||
|
||||
@ -24,17 +24,10 @@ export function removeSourceIfExists(map: maplibregl.Map, sourceId: string) {
|
||||
}
|
||||
}
|
||||
|
||||
// Ship 레이어/소스는 useGlobeShips에서 visibility 토글로 관리 (재생성 비용 회피)
|
||||
const GLOBE_NATIVE_LAYER_IDS = [
|
||||
'ships-globe-halo',
|
||||
'ships-globe-outline',
|
||||
'ships-globe',
|
||||
'ships-globe-label',
|
||||
'ships-globe-hover-halo',
|
||||
'ships-globe-hover-outline',
|
||||
'ships-globe-hover',
|
||||
'pair-lines-ml',
|
||||
'fc-lines-ml',
|
||||
'fleet-circles-ml-fill',
|
||||
'fleet-circles-ml',
|
||||
'pair-range-ml',
|
||||
'subcables-hitarea',
|
||||
@ -43,19 +36,23 @@ const GLOBE_NATIVE_LAYER_IDS = [
|
||||
'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 = [
|
||||
'ships-globe-src',
|
||||
'ships-globe-hover-src',
|
||||
'pair-lines-ml-src',
|
||||
'fc-lines-ml-src',
|
||||
'fleet-circles-ml-src',
|
||||
'fleet-circles-ml-fill-src',
|
||||
'pair-range-ml-src',
|
||||
'subcables-src',
|
||||
'subcables-pts-src',
|
||||
'vessel-track-line-src',
|
||||
'vessel-track-pts-src',
|
||||
];
|
||||
|
||||
export function clearGlobeNativeLayers(map: maplibregl.Map) {
|
||||
@ -104,6 +101,22 @@ export function setLayerVisibility(map: maplibregl.Map, layerId: string, visible
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* setLayoutProperty('visibility') wrapper — 현재 값과 동일하면 호출 생략.
|
||||
* MapLibre는 setLayoutProperty 호출 시 항상 style._changed = true를 설정하여
|
||||
* 모든 symbol layer의 placement를 재계산시킴. text-allow-overlap:false 라벨이
|
||||
* 충돌 검사에 의해 사라지는 문제를 방지하기 위해, 값이 실제로 바뀔 때만 호출.
|
||||
*/
|
||||
export function guardedSetVisibility(map: maplibregl.Map, layerId: string, target: 'visible' | 'none') {
|
||||
if (!map.getLayer(layerId)) return;
|
||||
try {
|
||||
if (map.getLayoutProperty(layerId, 'visibility') === target) return;
|
||||
map.setLayoutProperty(layerId, 'visibility', target);
|
||||
} catch {
|
||||
// ignore
|
||||
}
|
||||
}
|
||||
|
||||
export function cleanupLayers(
|
||||
map: maplibregl.Map,
|
||||
layerIds: string[],
|
||||
|
||||
@ -83,12 +83,8 @@ export function extractProjectionType(map: maplibregl.Map): MapProjectionId | un
|
||||
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;
|
||||
}
|
||||
// Canonical source: shared/lib/map/mapTilerKey.ts (re-exported for local usage)
|
||||
export { getMapTilerKey } from '../../../shared/lib/map/mapTilerKey';
|
||||
|
||||
export function getLayerId(value: unknown): string | null {
|
||||
if (!value || typeof value !== 'object') return null;
|
||||
@ -103,6 +99,19 @@ export function sanitizeDeckLayerList(value: unknown): 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;
|
||||
|
||||
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();
|
||||
@ -63,10 +63,11 @@ export function getGlobeBaseShipColor({
|
||||
if (rgb) return rgbToHex(lightenColor(rgb, 0.38));
|
||||
}
|
||||
|
||||
if (!isFiniteNumber(sog)) return 'rgba(100,116,139,0.55)';
|
||||
if (sog >= 10) return 'rgba(148,163,184,0.78)';
|
||||
if (sog >= 1) return 'rgba(100,116,139,0.74)';
|
||||
return 'rgba(71,85,105,0.68)';
|
||||
// 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(
|
||||
|
||||
@ -1,5 +1,6 @@
|
||||
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) {
|
||||
@ -54,7 +55,7 @@ export function getShipTooltipHtml({
|
||||
<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;">${msg}</div>` : ''}
|
||||
${msg ? `<div style="opacity:.7; font-size: 11px; margin-top: 4px;">${fmtIsoFull(msg)}</div>` : ''}
|
||||
${legacyHtml}
|
||||
</div>`,
|
||||
};
|
||||
@ -167,3 +168,54 @@ export function getFleetCircleTooltipHtml({
|
||||
</div>`,
|
||||
};
|
||||
}
|
||||
|
||||
function fmtMinutesKr(minutes: number): string {
|
||||
if (minutes < 60) return `${minutes}분`;
|
||||
if (minutes < 1440) return `${Math.round(minutes / 60)}시간`;
|
||||
return `${Math.round(minutes / 1440)}일`;
|
||||
}
|
||||
|
||||
export function getTrackLineTooltipHtml({
|
||||
name,
|
||||
pointCount,
|
||||
minutes,
|
||||
totalDistanceNm,
|
||||
}: {
|
||||
name: string;
|
||||
pointCount: number;
|
||||
minutes: number;
|
||||
totalDistanceNm: number;
|
||||
}) {
|
||||
return {
|
||||
html: `<div style="font-family: system-ui; font-size: 12px;">
|
||||
<div style="font-weight: 700; margin-bottom: 4px;">항적 · ${name}</div>
|
||||
<div>기간: <b>${fmtMinutesKr(minutes)}</b> · 포인트: <b>${pointCount}</b></div>
|
||||
<div>총 거리: <b>${totalDistanceNm.toFixed(1)} NM</b></div>
|
||||
</div>`,
|
||||
};
|
||||
}
|
||||
|
||||
export function getTrackPointTooltipHtml({
|
||||
name,
|
||||
sog,
|
||||
cog,
|
||||
heading,
|
||||
status,
|
||||
messageTimestamp,
|
||||
}: {
|
||||
name: string;
|
||||
sog: number;
|
||||
cog: number;
|
||||
heading: number;
|
||||
status: string;
|
||||
messageTimestamp: string;
|
||||
}) {
|
||||
return {
|
||||
html: `<div style="font-family: system-ui; font-size: 12px; white-space: nowrap;">
|
||||
<div style="font-weight: 700; margin-bottom: 4px;">${name}</div>
|
||||
<div>SOG: <b>${isFiniteNumber(sog) ? sog : '?'}</b> kt · COG: <b>${isFiniteNumber(cog) ? cog : '?'}</b>°</div>
|
||||
<div>Heading: <b>${isFiniteNumber(heading) ? heading : '?'}</b>° · 상태: ${status || '-'}</div>
|
||||
<div style="opacity:.7; font-size: 11px; margin-top: 4px;">${fmtIsoFull(messageTimestamp)}</div>
|
||||
</div>`,
|
||||
};
|
||||
}
|
||||
|
||||
@ -1,6 +1,7 @@
|
||||
import type { AisTarget } from '../../entities/aisTarget/model/types';
|
||||
import type { 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';
|
||||
@ -15,6 +16,13 @@ export type Map3DSettings = {
|
||||
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;
|
||||
@ -52,6 +60,15 @@ export interface Map3DProps {
|
||||
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 = {
|
||||
|
||||
@ -11,97 +11,161 @@ export function SubcableInfoPanel({ detail, color, onClose }: Props) {
|
||||
const countries = [...new Set(detail.landing_points.map((lp) => lp.country).filter(Boolean))];
|
||||
|
||||
return (
|
||||
<div className="map-info" style={{ maxWidth: 340 }}>
|
||||
<div className="map-info" style={{ width: 320 }}>
|
||||
<button className="close-btn" onClick={onClose} aria-label="close">
|
||||
✕
|
||||
</button>
|
||||
|
||||
<div style={{ marginBottom: 8 }}>
|
||||
{/* ── Header ── */}
|
||||
<div style={{ marginBottom: 10, paddingRight: 20 }}>
|
||||
<div style={{ display: 'flex', alignItems: 'center', gap: 8 }}>
|
||||
{color && (
|
||||
<div
|
||||
style={{
|
||||
width: 12,
|
||||
height: 12,
|
||||
width: 14,
|
||||
height: 14,
|
||||
borderRadius: 3,
|
||||
backgroundColor: color,
|
||||
flexShrink: 0,
|
||||
border: '1px solid rgba(255,255,255,0.15)',
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
<div style={{ fontSize: 16, fontWeight: 900, color: 'var(--accent)' }}>{detail.name}</div>
|
||||
<div style={{ fontSize: 15, fontWeight: 800, color: 'var(--accent)', lineHeight: 1.3 }}>
|
||||
{detail.name}
|
||||
</div>
|
||||
</div>
|
||||
<div style={{ fontSize: 10, color: 'var(--muted)', marginTop: 2 }}>
|
||||
Submarine Cable{detail.is_planned ? ' (Planned)' : ''}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="ir">
|
||||
<span className="il">길이</span>
|
||||
<span className="iv">{detail.length || '-'}</span>
|
||||
</div>
|
||||
<div className="ir">
|
||||
<span className="il">개통</span>
|
||||
<span className="iv">{detail.rfs || '-'}</span>
|
||||
</div>
|
||||
{detail.owners && (
|
||||
<div className="ir" style={{ alignItems: 'flex-start' }}>
|
||||
<span className="il">운영사</span>
|
||||
<span className="iv" style={{ wordBreak: 'break-word' }}>
|
||||
{detail.owners}
|
||||
{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>
|
||||
)}
|
||||
{detail.suppliers && (
|
||||
<div className="ir">
|
||||
<span className="il">공급사</span>
|
||||
<span className="iv">{detail.suppliers}</span>
|
||||
</div>
|
||||
)}
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* ── Info rows ── */}
|
||||
<div style={{ display: 'flex', flexDirection: 'column', gap: 1 }}>
|
||||
<InfoRow label="길이" value={detail.length} />
|
||||
<InfoRow label="개통" value={detail.rfs} />
|
||||
{detail.owners && <InfoRow label="운영사" value={detail.owners} wrap />}
|
||||
{detail.suppliers && <InfoRow label="공급사" value={detail.suppliers} wrap />}
|
||||
</div>
|
||||
|
||||
{/* ── Landing Points ── */}
|
||||
{landingCount > 0 && (
|
||||
<div style={{ marginTop: 8 }}>
|
||||
<div style={{ fontSize: 10, fontWeight: 700, color: 'var(--muted)', marginBottom: 4 }}>
|
||||
Landing Points ({landingCount}) · {countries.length} countries
|
||||
<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: 140,
|
||||
maxHeight: 160,
|
||||
overflowY: 'auto',
|
||||
fontSize: 10,
|
||||
lineHeight: 1.6,
|
||||
color: 'var(--text)',
|
||||
display: 'flex',
|
||||
flexDirection: 'column',
|
||||
gap: 2,
|
||||
}}
|
||||
>
|
||||
{detail.landing_points.map((lp) => (
|
||||
<div key={lp.id}>
|
||||
<span style={{ color: 'var(--muted)' }}>{lp.country}</span>{' '}
|
||||
<b>{lp.name}</b>
|
||||
{lp.is_tbd && <span style={{ color: '#F59E0B', marginLeft: 4 }}>TBD</span>}
|
||||
<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: 8, fontSize: 10, color: 'var(--muted)', fontStyle: 'italic' }}>
|
||||
<div
|
||||
style={{
|
||||
marginTop: 10,
|
||||
fontSize: 10,
|
||||
color: 'var(--muted)',
|
||||
fontStyle: 'italic',
|
||||
lineHeight: 1.5,
|
||||
}}
|
||||
>
|
||||
{detail.notes}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* ── Link ── */}
|
||||
{detail.url && (
|
||||
<div style={{ marginTop: 8 }}>
|
||||
<div style={{ marginTop: 10 }}>
|
||||
<a
|
||||
href={detail.url}
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
style={{ fontSize: 10, color: 'var(--accent)' }}
|
||||
style={{ fontSize: 10, color: 'var(--accent)', textDecoration: 'none' }}
|
||||
>
|
||||
Official website ↗
|
||||
상세정보 ↗
|
||||
</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>
|
||||
);
|
||||
}
|
||||
|
||||
@ -1,64 +1,134 @@
|
||||
type Props = {
|
||||
import { useState } from "react";
|
||||
|
||||
interface Props {
|
||||
total: number;
|
||||
fishing: number;
|
||||
transit: number;
|
||||
pairLinks: number;
|
||||
alarms: number;
|
||||
pollingStatus: "idle" | "loading" | "ready" | "error";
|
||||
lastFetchMinutes: number | null;
|
||||
clock: string;
|
||||
adminMode?: boolean;
|
||||
onLogoClick?: () => void;
|
||||
userName?: string;
|
||||
onLogout?: () => void;
|
||||
};
|
||||
theme?: "dark" | "light";
|
||||
onToggleTheme?: () => void;
|
||||
isSidebarOpen?: boolean;
|
||||
onMenuToggle?: () => void;
|
||||
}
|
||||
|
||||
export function Topbar({ total, fishing, transit, pairLinks, alarms, pollingStatus, lastFetchMinutes, clock, adminMode, onLogoClick, userName, onLogout }: Props) {
|
||||
const statusColor =
|
||||
pollingStatus === "ready" ? "#22C55E" : pollingStatus === "loading" ? "#F59E0B" : pollingStatus === "error" ? "#EF4444" : "var(--muted)";
|
||||
function StatChips({ total, fishing, transit, pairLinks, alarms }: Pick<Props, "total" | "fishing" | "transit" | "pairLinks" | "alarms">) {
|
||||
return (
|
||||
<div className="topbar">
|
||||
<div className="logo" onClick={onLogoClick} style={{ cursor: onLogoClick ? "pointer" : undefined }} title={adminMode ? "ADMIN" : undefined}>
|
||||
🛰 <span>WING</span> 조업감시·선단연관 {adminMode ? <span style={{ fontSize: 10, color: "#F59E0B" }}>(ADMIN)</span> : null}
|
||||
<>
|
||||
<div className="flex items-center gap-1 text-[10px] text-wing-muted">
|
||||
<b className="text-xs text-wing-text">{total}</b>척
|
||||
</div>
|
||||
<div className="stats">
|
||||
<div className="stat">
|
||||
DATA <b style={{ color: "#22C55E" }}>API</b>
|
||||
</div>
|
||||
<div className="stat">
|
||||
POLL{" "}
|
||||
<b style={{ color: statusColor }}>
|
||||
{pollingStatus.toUpperCase()}
|
||||
{lastFetchMinutes ? `(${lastFetchMinutes}m)` : ""}
|
||||
</b>
|
||||
</div>
|
||||
<div className="stat">
|
||||
전체 <b>{total}</b>척
|
||||
</div>
|
||||
<div className="stat">
|
||||
조업 <b style={{ color: "#22C55E" }}>{fishing}</b>
|
||||
</div>
|
||||
<div className="stat">
|
||||
항해 <b style={{ color: "#3B82F6" }}>{transit}</b>
|
||||
</div>
|
||||
<div className="stat">
|
||||
쌍연결 <b style={{ color: "#F59E0B" }}>{pairLinks}</b>
|
||||
</div>
|
||||
<div className="stat">
|
||||
경고 <b style={{ color: "#EF4444" }}>{alarms}</b>
|
||||
</div>
|
||||
<div className="flex items-center gap-1 text-[10px] text-wing-muted">
|
||||
조업 <b style={{ color: "#22C55E" }}>{fishing}</b>
|
||||
</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}>
|
||||
로그아웃
|
||||
<div className="flex items-center gap-1 text-[10px] text-wing-muted">
|
||||
항해 <b style={{ color: "#3B82F6" }}>{transit}</b>
|
||||
</div>
|
||||
<div className="flex items-center gap-1 text-[10px] text-wing-muted">
|
||||
쌍연결 <b style={{ color: "#F59E0B" }}>{pairLinks}</b>
|
||||
</div>
|
||||
<div className="flex items-center gap-1 text-[10px] text-wing-muted">
|
||||
경고 <b style={{ color: "#EF4444" }}>{alarms}</b>
|
||||
</div>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
export function Topbar({ total, fishing, transit, pairLinks, alarms, clock, adminMode, onLogoClick, userName, onLogout, theme, onToggleTheme, isSidebarOpen, onMenuToggle }: Props) {
|
||||
const [isStatsOpen, setIsStatsOpen] = useState(false);
|
||||
|
||||
return (
|
||||
<div className="col-span-full relative z-[1000]">
|
||||
<div className="flex h-[44px] items-center gap-2.5 border-b border-wing-border bg-wing-surface px-3.5">
|
||||
{/* 햄버거 메뉴 (모바일) */}
|
||||
{onMenuToggle && (
|
||||
<button
|
||||
className="flex cursor-pointer items-center justify-center rounded border border-wing-border bg-transparent p-1 text-wing-muted transition-colors hover:border-wing-accent hover:text-wing-text md:hidden"
|
||||
onClick={onMenuToggle}
|
||||
aria-label={isSidebarOpen ? "메뉴 닫기" : "메뉴 열기"}
|
||||
>
|
||||
<svg width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round">
|
||||
{isSidebarOpen ? (
|
||||
<>
|
||||
<line x1="18" y1="6" x2="6" y2="18" />
|
||||
<line x1="6" y1="6" x2="18" y2="18" />
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
<line x1="3" y1="6" x2="21" y2="6" />
|
||||
<line x1="3" y1="12" x2="21" y2="12" />
|
||||
<line x1="3" y1="18" x2="21" y2="18" />
|
||||
</>
|
||||
)}
|
||||
</svg>
|
||||
</button>
|
||||
)}
|
||||
|
||||
{/* 로고 */}
|
||||
<div
|
||||
className="flex items-center gap-1.5 whitespace-nowrap text-sm font-extrabold"
|
||||
onClick={onLogoClick}
|
||||
style={{ cursor: onLogoClick ? "pointer" : undefined }}
|
||||
title={adminMode ? "ADMIN" : undefined}
|
||||
>
|
||||
<span className="text-wing-accent">WING</span>
|
||||
<span className="hidden sm:inline">조업감시·선단연관</span>
|
||||
{adminMode ? <span className="text-[10px] text-wing-warning">(ADMIN)</span> : null}
|
||||
</div>
|
||||
|
||||
{/* 데스크톱: 인라인 통계 */}
|
||||
<div className="ml-auto hidden items-center gap-3.5 md:flex">
|
||||
<StatChips total={total} fishing={fishing} transit={transit} pairLinks={pairLinks} alarms={alarms} />
|
||||
</div>
|
||||
|
||||
{/* 항상 표시: 시계 + 테마 + 사용자 */}
|
||||
<div className="ml-auto flex shrink-0 items-center gap-2.5 md:ml-2.5">
|
||||
<span className="whitespace-nowrap text-[10px] font-semibold text-wing-accent">{clock}</span>
|
||||
{onToggleTheme && (
|
||||
<button
|
||||
className="cursor-pointer rounded border border-wing-border bg-transparent px-1.5 py-0.5 text-[9px] text-wing-muted transition-all duration-150 hover:border-wing-accent hover:text-wing-text"
|
||||
onClick={onToggleTheme}
|
||||
title={theme === "dark" ? "라이트 모드로 전환" : "다크 모드로 전환"}
|
||||
>
|
||||
{theme === "dark" ? "Light" : "Dark"}
|
||||
</button>
|
||||
)}
|
||||
{userName && (
|
||||
<div className="flex shrink-0 items-center gap-2">
|
||||
<span className="hidden whitespace-nowrap text-[10px] font-medium text-wing-text sm:inline">{userName}</span>
|
||||
{onLogout && (
|
||||
<button
|
||||
className="cursor-pointer whitespace-nowrap rounded border border-wing-border bg-transparent px-1.5 py-0.5 text-[9px] text-wing-muted transition-all duration-150 hover:border-wing-accent hover:text-wing-text"
|
||||
onClick={onLogout}
|
||||
>
|
||||
로그아웃
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* 모바일 통계 바 (펼침 시) — 레이아웃 흐름에 포함, 지도 영역 밀어내기 */}
|
||||
{isStatsOpen && (
|
||||
<div className="flex items-center justify-center gap-4 border-b border-wing-border bg-wing-surface px-3.5 py-2 md:hidden">
|
||||
<StatChips total={total} fishing={fishing} transit={transit} pairLinks={pairLinks} alarms={alarms} />
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* 모바일 통계 토글 탭 — topbar 하단 우측에 걸침 */}
|
||||
<button
|
||||
className="absolute bottom-0 right-3.5 z-10 translate-y-full cursor-pointer rounded-b-md border border-t-0 border-wing-border bg-wing-surface px-2 py-0.5 text-[8px] text-wing-muted transition-colors hover:text-wing-text md:hidden"
|
||||
onClick={() => setIsStatsOpen((v) => !v)}
|
||||
aria-label={isStatsOpen ? "통계 닫기" : "통계 열기"}
|
||||
>
|
||||
{isStatsOpen ? "▴" : "▾ 통계"}
|
||||
</button>
|
||||
</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>
|
||||
)}
|
||||
</>
|
||||
);
|
||||
}
|
||||
@ -1,3 +1,4 @@
|
||||
import tailwindcss from "@tailwindcss/vite";
|
||||
import react from "@vitejs/plugin-react";
|
||||
import { fileURLToPath } from "node:url";
|
||||
import { defineConfig, loadEnv } from "vite";
|
||||
@ -15,7 +16,7 @@ export default defineConfig(({ mode }) => {
|
||||
const snpApiTarget = env.VITE_SNP_API_TARGET || process.env.VITE_SNP_API_TARGET || "http://211.208.115.83:8041";
|
||||
|
||||
return {
|
||||
plugins: [react()],
|
||||
plugins: [tailwindcss(), react()],
|
||||
resolve: {
|
||||
alias: {
|
||||
// deck.gl (via loaders.gl) contains a few Node-only helper modules.
|
||||
|
||||
1885
package-lock.json
generated
1885
package-lock.json
generated
파일 크기가 너무 크기때문에 변경 상태를 표시하지 않습니다.
Load Diff
Some files were not shown because too many files have changed in this diff Show More
불러오는 중...
Reference in New Issue
Block a user