Compare commits

..

3 커밋

작성자 SHA1 메시지 날짜
cd60f553ee feat(map): 선박 외곽선 대비 및 줌 스케일링 개선
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-16 02:01:11 +09:00
c5d89c5641 refactor(map): Map3D.tsx hooks 추출 완료 (4558줄 → 510줄)
28개 useEffect + 30+ useCallback을 10개 커스텀 hook으로 추출:
- useMapInit: MapLibre 인스턴스 생성 + Deck 오버레이
- useProjectionToggle: Mercator↔Globe 전환
- useBaseMapToggle: 베이스맵 전환 + 수심/해도
- useZonesLayer: 수역 GeoJSON 레이어
- usePredictionVectors: 예측 벡터 레이어
- useGlobeShips: Globe 선박 아이콘/라벨/호버/클릭
- useGlobeOverlays: Globe pair/fc/fleet/range 레이어
- useGlobeInteraction: Globe 마우스 이벤트 + 툴팁
- useDeckLayers: Mercator + Globe Deck 레이어
- useFlyTo: 카메라 이동

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-16 00:41:11 +09:00
51090aca2a refactor(map): Map3D 모듈 분리 + 버그 4건 수정 + 수심 색상 개선
Map3D.tsx 단일 파일(5752줄)에서 1200줄을 16개 모듈로 추출하여
탐색성과 유지보수성 개선.

모듈 구조:
- types.ts, constants.ts: 타입/상수 정의
- lib/: setUtils, geometry, featureIds, mlExpressions, shipUtils,
  tooltips, globeShipIcon, mapCore, dashifyLine, layerHelpers, zoneUtils
- layers/: bathymetry, seamark
- hooks/: useHoverState

버그 수정:
- fix: Globe 선박 라벨 미표시 (permitted boolean→number + filter 갱신)
- fix: placement TypeError (isStyleLoaded 가드 + epoch change 시 remove 제거)
- fix: Globe easeTo 미지원 경고 (globe 모드에서 flyTo 사용)
- fix: 수심지도 얕은 구간 색상 미구분 (색상 팔레트 개선)

개선:
- 베이스맵 water 레이어 색상을 수심 그라데이션과 자연스럽게 연결
- 프로젝션 전환 settle 로직 최적화 (더블프레임→싱글프레임)
- glyphs URL 추가로 symbol 레이어 텍스트 렌더링 지원

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-15 23:57:38 +09:00
62개의 변경된 파일762개의 추가작업 그리고 5016개의 파일을 삭제

파일 보기

@ -1,41 +0,0 @@
name: Build and Deploy Wing
on:
push:
branches:
- main
jobs:
build-and-deploy:
runs-on: ubuntu-latest
steps:
- name: Checkout
uses: actions/checkout@v4
- name: Setup Node.js
uses: actions/setup-node@v4
with:
node-version: '24'
- name: Configure npm registry
run: |
echo "registry=https://nexus.gc-si.dev/repository/npm-public/" > .npmrc
echo "//nexus.gc-si.dev/repository/npm-public/:_auth=${{ secrets.NEXUS_NPM_AUTH }}" >> .npmrc
- name: Install dependencies
run: npm ci
- name: Build web
env:
VITE_MAPTILER_KEY: ${{ secrets.MAPTILER_KEY }}
VITE_MAPTILER_BASE_MAP_ID: dataviz-dark
VITE_AUTH_API_URL: https://guide.gc-si.dev
VITE_GOOGLE_CLIENT_ID: ${{ secrets.GOOGLE_CLIENT_ID }}
run: npm -w @wing/web run build
- name: Deploy to server
run: |
rm -rf /deploy/wing/*
cp -r apps/web/dist/* /deploy/wing/
echo "Deployed at $(date '+%Y-%m-%d %H:%M:%S')"
ls -la /deploy/wing/

파일 보기

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

파일 보기

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

파일 보기

@ -12,14 +12,11 @@
"dependencies": { "dependencies": {
"@deck.gl/aggregation-layers": "^9.2.7", "@deck.gl/aggregation-layers": "^9.2.7",
"@deck.gl/core": "^9.2.7", "@deck.gl/core": "^9.2.7",
"@deck.gl/geo-layers": "^9.2.7",
"@deck.gl/layers": "^9.2.7", "@deck.gl/layers": "^9.2.7",
"@deck.gl/mapbox": "^9.2.7", "@deck.gl/mapbox": "^9.2.7",
"@react-oauth/google": "^0.13.4",
"maplibre-gl": "^5.18.0", "maplibre-gl": "^5.18.0",
"react": "^19.2.0", "react": "^19.2.0",
"react-dom": "^19.2.0", "react-dom": "^19.2.0"
"react-router": "^7.13.0"
}, },
"devDependencies": { "devDependencies": {
"@eslint/js": "^9.39.1", "@eslint/js": "^9.39.1",

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

파일 보기

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

파일 보기

@ -105,7 +105,6 @@ body {
.map-area { .map-area {
position: relative; position: relative;
background: #010610;
} }
.sb { .sb {
@ -922,343 +921,6 @@ body {
border-radius: 8px; border-radius: 8px;
} }
/* ── Map Settings Panel ────────────────────────────────────────────── */
.map-settings-gear {
position: absolute;
top: 100px;
left: 10px;
z-index: 850;
width: 29px;
height: 29px;
border-radius: 4px;
border: 1px solid var(--border);
background: rgba(15, 23, 42, 0.92);
backdrop-filter: blur(8px);
color: var(--muted);
cursor: pointer;
display: flex;
align-items: center;
justify-content: center;
font-size: 16px;
transition: color 0.15s, border-color 0.15s;
user-select: none;
padding: 0;
}
.map-settings-gear:hover {
color: var(--text);
border-color: var(--accent);
}
.map-settings-gear.open {
color: var(--accent);
border-color: var(--accent);
}
.map-settings-panel {
position: absolute;
top: 10px;
left: 48px;
z-index: 850;
background: rgba(15, 23, 42, 0.95);
backdrop-filter: blur(8px);
border: 1px solid var(--border);
border-radius: 8px;
padding: 12px;
width: 240px;
max-height: calc(100vh - 80px);
overflow-y: auto;
}
.map-settings-panel .ms-title {
font-size: 11px;
font-weight: 700;
color: var(--text);
letter-spacing: 1px;
margin-bottom: 10px;
}
.map-settings-panel .ms-section {
margin-bottom: 10px;
}
.map-settings-panel .ms-label {
font-size: 10px;
font-weight: 600;
color: var(--text);
letter-spacing: 0.5px;
margin-bottom: 5px;
}
.map-settings-panel .ms-row {
display: flex;
align-items: center;
gap: 8px;
margin-bottom: 4px;
}
.map-settings-panel .ms-color-input {
width: 24px;
height: 24px;
border: 1px solid var(--border);
border-radius: 4px;
padding: 0;
cursor: pointer;
background: transparent;
flex-shrink: 0;
}
.map-settings-panel .ms-color-input::-webkit-color-swatch-wrapper {
padding: 1px;
}
.map-settings-panel .ms-color-input::-webkit-color-swatch {
border: none;
border-radius: 2px;
}
.map-settings-panel .ms-hex {
font-size: 9px;
color: #94a3b8;
font-family: monospace;
}
.map-settings-panel .ms-depth-label {
font-size: 10px;
color: var(--text);
min-width: 48px;
text-align: right;
}
.map-settings-panel select {
font-size: 10px;
padding: 4px 8px;
border-radius: 4px;
border: 1px solid var(--border);
background: var(--card);
color: var(--text);
cursor: pointer;
outline: none;
width: 100%;
}
.map-settings-panel select:focus {
border-color: var(--accent);
}
.map-settings-panel .ms-reset {
width: 100%;
font-size: 9px;
padding: 5px 8px;
border-radius: 4px;
border: 1px solid var(--border);
background: var(--card);
color: var(--muted);
cursor: pointer;
transition: all 0.15s;
margin-top: 4px;
}
.map-settings-panel .ms-reset:hover {
color: var(--text);
border-color: var(--accent);
}
/* ── Depth Legend ──────────────────────────────────────────────────── */
.depth-legend {
position: absolute;
bottom: 44px;
left: 10px;
z-index: 800;
background: rgba(15, 23, 42, 0.92);
backdrop-filter: blur(8px);
border: 1px solid var(--border);
border-radius: 8px;
padding: 8px;
display: flex;
gap: 6px;
align-items: stretch;
}
.depth-legend__bar {
width: 14px;
border-radius: 3px;
min-height: 120px;
}
.depth-legend__ticks {
display: flex;
flex-direction: column;
justify-content: space-between;
font-size: 8px;
color: var(--muted);
font-family: monospace;
padding: 1px 0;
}
/* ── Auth pages ──────────────────────────────────────────────────── */
.auth-page {
height: 100vh;
display: flex;
align-items: center;
justify-content: center;
background: linear-gradient(135deg, #020617 0%, #0f172a 50%, #020617 100%);
}
.auth-card {
background: var(--panel);
border: 1px solid var(--border);
border-radius: 16px;
padding: 40px 36px;
width: 360px;
text-align: center;
}
.auth-logo {
font-size: 36px;
font-weight: 900;
color: var(--accent);
letter-spacing: 4px;
margin-bottom: 4px;
}
.auth-title {
font-size: 16px;
font-weight: 700;
color: var(--text);
margin-bottom: 8px;
}
.auth-subtitle {
font-size: 12px;
color: var(--muted);
margin-bottom: 24px;
}
.auth-error {
font-size: 11px;
color: var(--crit);
background: rgba(239, 68, 68, 0.08);
border: 1px solid rgba(239, 68, 68, 0.2);
border-radius: 8px;
padding: 8px 12px;
margin-bottom: 16px;
}
.auth-google-btn {
display: flex;
justify-content: center;
margin-bottom: 12px;
}
.auth-dev-btn {
width: 100%;
padding: 10px;
border-radius: 8px;
border: 1px solid var(--border);
background: var(--card);
color: var(--muted);
font-size: 12px;
cursor: pointer;
transition: all 0.15s;
margin-bottom: 12px;
}
.auth-dev-btn:hover {
color: var(--text);
border-color: var(--accent);
}
.auth-footer {
font-size: 10px;
color: var(--muted);
margin-top: 16px;
}
.auth-status-icon {
font-size: 48px;
margin-bottom: 12px;
}
.auth-message {
font-size: 13px;
color: var(--muted);
line-height: 1.6;
margin-bottom: 16px;
}
.auth-message b {
color: var(--text);
}
.auth-link-btn {
background: none;
border: none;
color: var(--accent);
font-size: 12px;
cursor: pointer;
text-decoration: underline;
padding: 4px 8px;
}
.auth-link-btn:hover {
color: var(--text);
}
.auth-loading {
height: 100vh;
display: flex;
align-items: center;
justify-content: center;
background: var(--bg);
}
.auth-loading__spinner {
width: 32px;
height: 32px;
border: 3px solid rgba(148, 163, 184, 0.28);
border-top-color: var(--accent);
border-radius: 50%;
animation: map-loader-spin 0.7s linear infinite;
}
/* ── Topbar user ─────────────────────────────────────────────────── */
.topbar-user {
display: flex;
align-items: center;
gap: 8px;
margin-left: 10px;
flex-shrink: 0;
}
.topbar-user__name {
font-size: 10px;
color: var(--text);
font-weight: 500;
white-space: nowrap;
}
.topbar-user__logout {
font-size: 9px;
color: var(--muted);
background: none;
border: 1px solid var(--border);
border-radius: 3px;
padding: 2px 6px;
cursor: pointer;
transition: all 0.15s;
white-space: nowrap;
}
.topbar-user__logout:hover {
color: var(--text);
border-color: var(--accent);
}
@media (max-width: 920px) { @media (max-width: 920px) {
.app { .app {
grid-template-columns: 1fr; grid-template-columns: 1fr;

파일 보기

@ -1,32 +0,0 @@
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,52 +0,0 @@
import { useEffect, useState } from 'react';
import type { SubcableGeoJson, SubcableDetailsIndex, SubcableDetail } from '../model/types';
interface SubcableData {
geo: SubcableGeoJson;
details: Map<string, SubcableDetail>;
}
export function useSubcables(
geoUrl = '/data/subcables/cable-geo.json',
detailsUrl = '/data/subcables/cable-details.min.json',
) {
const [data, setData] = useState<SubcableData | null>(null);
const [error, setError] = useState<string | null>(null);
useEffect(() => {
let cancelled = false;
async function run() {
try {
setError(null);
const [geoRes, detailsRes] = await Promise.all([
fetch(geoUrl),
fetch(detailsUrl),
]);
if (!geoRes.ok) throw new Error(`Failed to load subcable geo: ${geoRes.status}`);
if (!detailsRes.ok) throw new Error(`Failed to load subcable details: ${detailsRes.status}`);
const geo = (await geoRes.json()) as SubcableGeoJson;
const detailsJson = (await detailsRes.json()) as SubcableDetailsIndex;
if (cancelled) return;
const details = new Map<string, SubcableDetail>();
for (const [id, detail] of Object.entries(detailsJson.by_id)) {
details.set(id, detail);
}
setData({ geo, details });
} catch (e) {
if (cancelled) return;
setError(e instanceof Error ? e.message : String(e));
}
}
void run();
return () => {
cancelled = true;
};
}, [geoUrl, detailsUrl]);
return { data, error };
}

파일 보기

@ -1,39 +0,0 @@
export interface SubcableFeatureProperties {
id: string;
name: string;
color: string;
feature_id: string;
coordinates: [number, number];
}
export type SubcableGeoJson = GeoJSON.FeatureCollection<
GeoJSON.MultiLineString,
SubcableFeatureProperties
>;
export interface SubcableLandingPoint {
id: string;
name: string;
country: string;
is_tbd: boolean;
}
export interface SubcableDetail {
id: string;
name: string;
length: string | null;
rfs: string | null;
rfs_year: number | null;
is_planned: boolean;
owners: string | null;
suppliers: string | null;
landing_points: SubcableLandingPoint[];
notes: string | null;
url: string | null;
}
export interface SubcableDetailsIndex {
version: number;
generated_at: string;
by_id: Record<string, SubcableDetail>;
}

파일 보기

@ -1,32 +0,0 @@
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);
}
}

파일 보기

@ -1,115 +0,0 @@
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 };
}

파일 보기

@ -1,39 +0,0 @@
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];
}

파일 보기

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

파일 보기

@ -1,221 +0,0 @@
import { useState } from 'react';
import type { MapStyleSettings, MapLabelLanguage, DepthFontSize, DepthColorStop } from './types';
import { DEFAULT_MAP_STYLE_SETTINGS } from './types';
interface MapSettingsPanelProps {
value: MapStyleSettings;
onChange: (next: MapStyleSettings) => void;
}
const LANGUAGES: { value: MapLabelLanguage; label: string }[] = [
{ value: 'ko', label: '한국어' },
{ value: 'en', label: 'English' },
{ value: 'ja', label: '日本語' },
{ value: 'zh', label: '中文' },
{ value: 'local', label: '현지어' },
];
const FONT_SIZES: { value: DepthFontSize; label: string }[] = [
{ value: 'small', label: '소' },
{ value: 'medium', label: '중' },
{ value: 'large', label: '대' },
];
function depthLabel(depth: number): string {
return `${Math.abs(depth).toLocaleString()}m`;
}
function hexToRgb(hex: string): [number, number, number] {
return [
parseInt(hex.slice(1, 3), 16),
parseInt(hex.slice(3, 5), 16),
parseInt(hex.slice(5, 7), 16),
];
}
function rgbToHex(r: number, g: number, b: number): string {
return `#${[r, g, b].map((c) => Math.round(Math.max(0, Math.min(255, c))).toString(16).padStart(2, '0')).join('')}`;
}
function interpolateGradient(stops: DepthColorStop[]): DepthColorStop[] {
if (stops.length < 2) return stops;
const sorted = [...stops].sort((a, b) => a.depth - b.depth);
const first = sorted[0];
const last = sorted[sorted.length - 1];
const [r1, g1, b1] = hexToRgb(first.color);
const [r2, g2, b2] = hexToRgb(last.color);
return sorted.map((stop, i) => {
if (i === 0 || i === sorted.length - 1) return stop;
const t = i / (sorted.length - 1);
return {
depth: stop.depth,
color: rgbToHex(
r1 + (r2 - r1) * t,
g1 + (g2 - g1) * t,
b1 + (b2 - b1) * t,
),
};
});
}
export function MapSettingsPanel({ value, onChange }: MapSettingsPanelProps) {
const [open, setOpen] = useState(false);
const [autoGradient, setAutoGradient] = useState(false);
const update = <K extends keyof MapStyleSettings>(key: K, val: MapStyleSettings[K]) => {
onChange({ ...value, [key]: val });
};
const updateDepthStop = (index: number, color: string) => {
const next = value.depthStops.map((s, i) => (i === index ? { ...s, color } : s));
if (autoGradient && (index === 0 || index === next.length - 1)) {
update('depthStops', interpolateGradient(next));
} else {
update('depthStops', next);
}
};
const toggleAutoGradient = () => {
const next = !autoGradient;
setAutoGradient(next);
if (next) {
update('depthStops', interpolateGradient(value.depthStops));
}
};
return (
<>
<button
className={`map-settings-gear${open ? ' open' : ''}`}
onClick={() => setOpen((p) => !p)}
title="지도 설정"
type="button"
>
</button>
{open && (
<div className="map-settings-panel">
<div className="ms-title"> </div>
{/* ── Language ──────────────────────────────────── */}
<div className="ms-section">
<div className="ms-label"> </div>
<select
value={value.labelLanguage}
onChange={(e) => update('labelLanguage', e.target.value as MapLabelLanguage)}
>
{LANGUAGES.map((l) => (
<option key={l.value} value={l.value}>
{l.label}
</option>
))}
</select>
</div>
{/* ── Land color ────────────────────────────────── */}
<div className="ms-section">
<div className="ms-label"> </div>
<div className="ms-row">
<input
type="color"
className="ms-color-input"
value={value.landColor}
onChange={(e) => update('landColor', e.target.value)}
/>
<span className="ms-hex">{value.landColor}</span>
</div>
</div>
{/* ── Water color ───────────────────────────────── */}
<div className="ms-section">
<div className="ms-label"> </div>
<div className="ms-row">
<input
type="color"
className="ms-color-input"
value={value.waterBaseColor}
onChange={(e) => update('waterBaseColor', e.target.value)}
/>
<span className="ms-hex">{value.waterBaseColor}</span>
</div>
</div>
{/* ── Depth gradient ────────────────────────────── */}
<div className="ms-section">
<div className="ms-label" style={{ display: 'flex', alignItems: 'center', justifyContent: 'space-between' }}>
<span
className={`tog-btn${autoGradient ? ' on' : ''}`}
style={{ fontSize: 8, padding: '1px 5px', marginLeft: 8 }}
onClick={toggleAutoGradient}
title="최소/최대 색상 기준으로 중간 구간을 자동 보간합니다"
>
</span>
</div>
{value.depthStops.map((stop, i) => {
const isEdge = i === 0 || i === value.depthStops.length - 1;
const dimmed = autoGradient && !isEdge;
return (
<div className="ms-row" key={stop.depth} style={dimmed ? { opacity: 0.5 } : undefined}>
<span className="ms-depth-label">{depthLabel(stop.depth)}</span>
<input
type="color"
className="ms-color-input"
value={stop.color}
onChange={(e) => updateDepthStop(i, e.target.value)}
disabled={dimmed}
/>
<span className="ms-hex">{stop.color}</span>
</div>
);
})}
</div>
{/* ── Depth font size ───────────────────────────── */}
<div className="ms-section">
<div className="ms-label"> </div>
<div className="tog" style={{ gap: 3 }}>
{FONT_SIZES.map((fs) => (
<div
key={fs.value}
className={`tog-btn${value.depthFontSize === fs.value ? ' on' : ''}`}
onClick={() => update('depthFontSize', fs.value)}
>
{fs.label}
</div>
))}
</div>
</div>
{/* ── Depth font color ──────────────────────────── */}
<div className="ms-section">
<div className="ms-label"> </div>
<div className="ms-row">
<input
type="color"
className="ms-color-input"
value={value.depthFontColor}
onChange={(e) => update('depthFontColor', e.target.value)}
/>
<span className="ms-hex">{value.depthFontColor}</span>
</div>
</div>
{/* ── Reset ─────────────────────────────────────── */}
<button
className="ms-reset"
type="button"
onClick={() => {
onChange(DEFAULT_MAP_STYLE_SETTINGS);
setAutoGradient(false);
}}
>
</button>
</div>
)}
</>
);
}

파일 보기

@ -1,32 +0,0 @@
export type MapLabelLanguage = 'ko' | 'en' | 'ja' | 'zh' | 'local';
export type DepthFontSize = 'small' | 'medium' | 'large';
export interface DepthColorStop {
depth: number;
color: string;
}
export interface MapStyleSettings {
labelLanguage: MapLabelLanguage;
landColor: string;
waterBaseColor: string;
depthStops: DepthColorStop[];
depthFontSize: DepthFontSize;
depthFontColor: string;
}
export const DEFAULT_MAP_STYLE_SETTINGS: MapStyleSettings = {
labelLanguage: 'ko',
landColor: '#1a1a2e',
waterBaseColor: '#14606e',
depthStops: [
{ depth: -8000, color: '#010610' },
{ depth: -4000, color: '#030c1c' },
{ depth: -2000, color: '#041022' },
{ depth: -1000, color: '#051529' },
{ depth: -500, color: '#061a30' },
{ depth: -100, color: '#08263d' },
],
depthFontSize: 'medium',
depthFontColor: '#e2e8f0',
};

파일 보기

@ -6,7 +6,6 @@ export type MapToggleState = {
fleetCircles: boolean; fleetCircles: boolean;
predictVectors: boolean; predictVectors: boolean;
shipLabels: boolean; shipLabels: boolean;
subcables: boolean;
}; };
type Props = { type Props = {
@ -23,7 +22,6 @@ export function MapToggles({ value, onToggle }: Props) {
{ id: "zones", label: "수역 표시" }, { id: "zones", label: "수역 표시" },
{ id: "predictVectors", label: "예측 벡터" }, { id: "predictVectors", label: "예측 벡터" },
{ id: "shipLabels", label: "선박명 표시" }, { id: "shipLabels", label: "선박명 표시" },
{ id: "subcables", label: "해저케이블" },
]; ];
return ( return (

파일 보기

@ -1,6 +1,4 @@
import { useCallback, useEffect, useMemo, useRef, useState } from "react"; import { useEffect, useMemo, useRef, useState } from "react";
import { useAuth } from "../../shared/auth";
import { usePersistedState } from "../../shared/hooks";
import { useAisTargetPolling } from "../../features/aisPolling/useAisTargetPolling"; import { useAisTargetPolling } from "../../features/aisPolling/useAisTargetPolling";
import { Map3DSettingsToggles } from "../../features/map3dSettings/Map3DSettingsToggles"; import { Map3DSettingsToggles } from "../../features/map3dSettings/Map3DSettingsToggles";
import type { MapToggleState } from "../../features/mapToggles/MapToggles"; import type { MapToggleState } from "../../features/mapToggles/MapToggles";
@ -13,27 +11,17 @@ import { buildLegacyVesselIndex } from "../../entities/legacyVessel/lib";
import type { LegacyVesselIndex } from "../../entities/legacyVessel/lib"; import type { LegacyVesselIndex } from "../../entities/legacyVessel/lib";
import type { LegacyVesselDataset } from "../../entities/legacyVessel/model/types"; import type { LegacyVesselDataset } from "../../entities/legacyVessel/model/types";
import { useZones } from "../../entities/zone/api/useZones"; import { useZones } from "../../entities/zone/api/useZones";
import { useSubcables } from "../../entities/subcable/api/useSubcables";
import type { VesselTypeCode } from "../../entities/vessel/model/types"; import type { VesselTypeCode } from "../../entities/vessel/model/types";
import { AisInfoPanel } from "../../widgets/aisInfo/AisInfoPanel"; import { AisInfoPanel } from "../../widgets/aisInfo/AisInfoPanel";
import { AisTargetList } from "../../widgets/aisTargetList/AisTargetList"; import { AisTargetList } from "../../widgets/aisTargetList/AisTargetList";
import { AlarmsPanel } from "../../widgets/alarms/AlarmsPanel"; import { AlarmsPanel } from "../../widgets/alarms/AlarmsPanel";
import { MapLegend } from "../../widgets/legend/MapLegend"; import { MapLegend } from "../../widgets/legend/MapLegend";
import { Map3D, type BaseMapId, type Map3DSettings, type MapProjectionId } from "../../widgets/map3d/Map3D"; import { Map3D, type BaseMapId, type Map3DSettings, type MapProjectionId } from "../../widgets/map3d/Map3D";
import type { MapViewState } from "../../widgets/map3d/types";
import { RelationsPanel } from "../../widgets/relations/RelationsPanel"; import { RelationsPanel } from "../../widgets/relations/RelationsPanel";
import { SpeedProfilePanel } from "../../widgets/speed/SpeedProfilePanel"; import { SpeedProfilePanel } from "../../widgets/speed/SpeedProfilePanel";
import { Topbar } from "../../widgets/topbar/Topbar"; import { Topbar } from "../../widgets/topbar/Topbar";
import { VesselInfoPanel } from "../../widgets/info/VesselInfoPanel"; import { VesselInfoPanel } from "../../widgets/info/VesselInfoPanel";
import { SubcableInfoPanel } from "../../widgets/subcableInfo/SubcableInfoPanel";
import { VesselList } from "../../widgets/vesselList/VesselList"; import { VesselList } from "../../widgets/vesselList/VesselList";
import type { ActiveTrack } from "../../entities/vesselTrack/model/types";
import { fetchVesselTrack } from "../../entities/vesselTrack/api/fetchTrack";
import { MapSettingsPanel } from "../../features/mapSettings/MapSettingsPanel";
import { DepthLegend } from "../../widgets/legend/DepthLegend";
import { DEFAULT_MAP_STYLE_SETTINGS } from "../../features/mapSettings/types";
import type { MapStyleSettings } from "../../features/mapSettings/types";
import { fmtDateTimeFull, fmtIsoFull } from "../../shared/lib/datetime";
import { import {
buildLegacyHitMap, buildLegacyHitMap,
computeCountsByType, computeCountsByType,
@ -53,6 +41,13 @@ const AIS_CENTER = {
radiusMeters: 2_000_000, radiusMeters: 2_000_000,
}; };
function fmtLocal(iso: string | null) {
if (!iso) return "-";
const d = new Date(iso);
if (Number.isNaN(d.getTime())) return iso;
return d.toLocaleString("ko-KR", { hour12: false });
}
type Bbox = [number, number, number, number]; // [lonMin, latMin, lonMax, latMax] type Bbox = [number, number, number, number]; // [lonMin, latMin, lonMax, latMax]
type FleetRelationSortMode = "count" | "range"; type FleetRelationSortMode = "count" | "range";
@ -73,10 +68,8 @@ function useLegacyIndex(data: LegacyVesselDataset | null): LegacyVesselIndex | n
} }
export function DashboardPage() { export function DashboardPage() {
const { user, logout } = useAuth();
const { data: zones, error: zonesError } = useZones(); const { data: zones, error: zonesError } = useZones();
const { data: legacyData, error: legacyError } = useLegacyVessels(); const { data: legacyData, error: legacyError } = useLegacyVessels();
const { data: subcableData } = useSubcables();
const legacyIndex = useLegacyIndex(legacyData); const legacyIndex = useLegacyIndex(legacyData);
const [viewBbox, setViewBbox] = useState<Bbox | null>(null); const [viewBbox, setViewBbox] = useState<Bbox | null>(null);
@ -85,10 +78,11 @@ export function DashboardPage() {
const [apiBbox, setApiBbox] = useState<string | undefined>(undefined); const [apiBbox, setApiBbox] = useState<string | undefined>(undefined);
const { targets, snapshot } = useAisTargetPolling({ const { targets, snapshot } = useAisTargetPolling({
chnprmshipMinutes: 120, initialMinutes: 60,
bootstrapMinutes: 10,
incrementalMinutes: 2, incrementalMinutes: 2,
intervalMs: 60_000, intervalMs: 60_000,
retentionMinutes: 120, retentionMinutes: 90,
bbox: useApiBbox ? apiBbox : undefined, bbox: useApiBbox ? apiBbox : undefined,
centerLon: useApiBbox ? undefined : AIS_CENTER.lon, centerLon: useApiBbox ? undefined : AIS_CENTER.lon,
centerLat: useApiBbox ? undefined : AIS_CENTER.lat, centerLat: useApiBbox ? undefined : AIS_CENTER.lat,
@ -101,77 +95,48 @@ export function DashboardPage() {
const [hoveredFleetMmsiSet, setHoveredFleetMmsiSet] = useState<number[]>([]); const [hoveredFleetMmsiSet, setHoveredFleetMmsiSet] = useState<number[]>([]);
const [hoveredPairMmsiSet, setHoveredPairMmsiSet] = useState<number[]>([]); const [hoveredPairMmsiSet, setHoveredPairMmsiSet] = useState<number[]>([]);
const [hoveredFleetOwnerKey, setHoveredFleetOwnerKey] = useState<string | null>(null); const [hoveredFleetOwnerKey, setHoveredFleetOwnerKey] = useState<string | null>(null);
const uid = user?.id ?? null; const [typeEnabled, setTypeEnabled] = useState<Record<VesselTypeCode, boolean>>({
const [typeEnabled, setTypeEnabled] = usePersistedState<Record<VesselTypeCode, boolean>>( PT: true,
uid, 'typeEnabled', { PT: true, "PT-S": true, GN: true, OT: true, PS: true, FC: true }, "PT-S": true,
); GN: true,
const [showTargets, setShowTargets] = usePersistedState(uid, 'showTargets', true); OT: true,
const [showOthers, setShowOthers] = usePersistedState(uid, 'showOthers', false); PS: true,
FC: true,
// 레거시 베이스맵 비활성 — 향후 위성/라이트 등 추가 시 재활용
// eslint-disable-next-line @typescript-eslint/no-unused-vars
const [baseMap, _setBaseMap] = useState<BaseMapId>("enhanced");
// 항상 mercator로 시작 — 3D 전환은 globe 레이어 준비 후 사용자가 수동 전환
const [projection, setProjection] = useState<MapProjectionId>('mercator');
const [mapStyleSettings, setMapStyleSettings] = usePersistedState<MapStyleSettings>(uid, 'mapStyleSettings', DEFAULT_MAP_STYLE_SETTINGS);
const [overlays, setOverlays] = usePersistedState<MapToggleState>(uid, 'overlays', {
pairLines: true, pairRange: true, fcLines: true, zones: true,
fleetCircles: true, predictVectors: true, shipLabels: true, subcables: false,
}); });
const [fleetRelationSortMode, setFleetRelationSortMode] = usePersistedState<FleetRelationSortMode>(uid, 'sortMode', "count"); const [showTargets, setShowTargets] = useState(true);
const [showOthers, setShowOthers] = useState(false);
const [alarmKindEnabled, setAlarmKindEnabled] = usePersistedState<Record<LegacyAlarmKind, boolean>>( const [baseMap, setBaseMap] = useState<BaseMapId>("enhanced");
uid, 'alarmKindEnabled', const [projection, setProjection] = useState<MapProjectionId>("mercator");
() => Object.fromEntries(LEGACY_ALARM_KINDS.map((k) => [k, true])) as Record<LegacyAlarmKind, boolean>,
); const [overlays, setOverlays] = useState<MapToggleState>({
pairLines: true,
pairRange: true,
fcLines: true,
zones: true,
fleetCircles: true,
predictVectors: true,
shipLabels: true,
});
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 [fleetFocus, setFleetFocus] = useState<{ id: string | number; center: [number, number]; zoom?: number } | undefined>(undefined);
const [hoveredCableId, setHoveredCableId] = useState<string | null>(null); const [settings, setSettings] = useState<Map3DSettings>({
const [selectedCableId, setSelectedCableId] = useState<string | null>(null); showShips: true,
showDensity: false,
// 항적 (vessel track) showSeamark: false,
const [activeTrack, setActiveTrack] = useState<ActiveTrack | null>(null);
const [trackContextMenu, setTrackContextMenu] = useState<{ x: number; y: number; mmsi: number; vesselName: string } | null>(null);
const handleOpenTrackMenu = useCallback((info: { x: number; y: number; mmsi: number; vesselName: string }) => {
setTrackContextMenu(info);
}, []);
const handleCloseTrackMenu = useCallback(() => setTrackContextMenu(null), []);
const handleRequestTrack = useCallback(async (mmsi: number, minutes: number) => {
try {
const res = await fetchVesselTrack(mmsi, minutes);
if (res.success && res.data.length > 0) {
const sorted = [...res.data].sort(
(a, b) => new Date(a.messageTimestamp).getTime() - new Date(b.messageTimestamp).getTime(),
);
setActiveTrack({ mmsi, minutes, points: sorted, fetchedAt: Date.now() });
} else {
console.warn('Track: no data', res.message);
}
} catch (e) {
console.warn('Track fetch failed:', e);
}
}, []);
const [settings, setSettings] = usePersistedState<Map3DSettings>(uid, 'map3dSettings', {
showShips: true, showDensity: false, showSeamark: false,
}); });
const [mapView, setMapView] = usePersistedState<MapViewState | null>(uid, 'mapView', null);
const [isProjectionLoading, setIsProjectionLoading] = useState(false); const [isProjectionLoading, setIsProjectionLoading] = useState(false);
// 초기값 false: globe 레이어가 백그라운드에서 준비될 때까지 토글 비활성화
const [isGlobeShipsReady, setIsGlobeShipsReady] = useState(false);
const handleProjectionLoadingChange = useCallback((loading: boolean) => {
setIsProjectionLoading(loading);
}, []);
const showMapLoader = isProjectionLoading;
// globe 레이어 미준비 또는 전환 중일 때 토글 비활성화
const isProjectionToggleDisabled = !isGlobeShipsReady || isProjectionLoading;
const [clock, setClock] = useState(() => fmtDateTimeFull(new Date())); const [clock, setClock] = useState(() => new Date().toLocaleString("ko-KR", { hour12: false }));
useEffect(() => { useEffect(() => {
const id = window.setInterval(() => setClock(fmtDateTimeFull(new Date())), 1000); const id = window.setInterval(() => setClock(new Date().toLocaleString("ko-KR", { hour12: false })), 1000);
return () => window.clearInterval(id); return () => window.clearInterval(id);
}, []); }, []);
@ -331,8 +296,6 @@ export function DashboardPage() {
clock={clock} clock={clock}
adminMode={adminMode} adminMode={adminMode}
onLogoClick={onLogoClick} onLogoClick={onLogoClick}
userName={user?.name}
onLogout={logout}
/> />
<div className="sidebar"> <div className="sidebar">
@ -382,28 +345,30 @@ export function DashboardPage() {
</div> </div>
<div className="sb"> <div className="sb">
<div className="sb-t" style={{ display: "flex", alignItems: "center" }}> <div className="sb-t"> </div>
<div style={{ flex: 1 }} />
<div
className={`tog-btn ${projection === "globe" ? "on" : ""}${isProjectionToggleDisabled ? " disabled" : ""}`}
onClick={isProjectionToggleDisabled ? undefined : () => setProjection((p) => (p === "globe" ? "mercator" : "globe"))}
title={isProjectionToggleDisabled ? "3D 모드 준비 중..." : "3D 지구본 투영: 드래그로 회전, 휠로 확대/축소"}
style={{ fontSize: 9, padding: "2px 8px", opacity: isProjectionToggleDisabled ? 0.4 : 1, cursor: isProjectionToggleDisabled ? "not-allowed" : "pointer" }}
>
3D
</div>
</div>
<MapToggles value={overlays} onToggle={(k) => setOverlays((prev) => ({ ...prev, [k]: !prev[k] }))} /> <MapToggles value={overlays} onToggle={(k) => setOverlays((prev) => ({ ...prev, [k]: !prev[k] }))} />
{/* enhanced , <div style={{ fontSize: 9, fontWeight: 700, color: "var(--muted)", letterSpacing: 1.5, marginTop: 8, marginBottom: 6 }}>
<div className="tog" style={{ flexWrap: "nowrap", alignItems: "center", marginTop: 8 }}>
</div>
<div className="tog" style={{ flexWrap: "nowrap", alignItems: "center" }}>
<div className={`tog-btn ${baseMap === "enhanced" ? "on" : ""}`} onClick={() => setBaseMap("enhanced")} title="현재 지도(해저지형/3D 표현 포함)"> <div className={`tog-btn ${baseMap === "enhanced" ? "on" : ""}`} onClick={() => setBaseMap("enhanced")} title="현재 지도(해저지형/3D 표현 포함)">
</div> </div>
<div className={`tog-btn ${baseMap === "legacy" ? "on" : ""}`} onClick={() => setBaseMap("legacy")} title="레거시 대시보드(Carto Dark) 베이스맵"> <div className={`tog-btn ${baseMap === "legacy" ? "on" : ""}`} onClick={() => setBaseMap("legacy")} title="레거시 대시보드(Carto Dark) 베이스맵">
</div> </div>
</div> */}
<div style={{ flex: 1 }} />
<div
className={`tog-btn ${projection === "globe" ? "on" : ""}`}
onClick={() => setProjection((p) => (p === "globe" ? "mercator" : "globe"))}
title="지구본(globe) 투영: 드래그로 회전, 휠로 확대/축소"
>
</div>
</div>
{/* Attribution (license) stays visible in the map UI; no need to repeat it here. */}
</div> </div>
<div className="sb"> <div className="sb">
@ -566,7 +531,7 @@ export function DashboardPage() {
</div> </div>
<div style={{ marginTop: 6, color: "var(--muted)", fontSize: 10 }}> fetch</div> <div style={{ marginTop: 6, color: "var(--muted)", fontSize: 10 }}> fetch</div>
<div> <div>
{fmtIsoFull(snapshot.lastFetchAt)}{" "} {fmtLocal(snapshot.lastFetchAt)}{" "}
<span style={{ color: "var(--muted)", fontSize: 10 }}> <span style={{ color: "var(--muted)", fontSize: 10 }}>
({snapshot.lastFetchMinutes ?? "-"}m, inserted {snapshot.lastInserted}, upserted {snapshot.lastUpserted}) ({snapshot.lastFetchMinutes ?? "-"}m, inserted {snapshot.lastInserted}, upserted {snapshot.lastUpserted})
</span> </span>
@ -590,7 +555,7 @@ export function DashboardPage() {
<span style={{ color: "var(--muted)", fontSize: 10 }}>/ {targetsInScope.length}</span> <span style={{ color: "var(--muted)", fontSize: 10 }}>/ {targetsInScope.length}</span>
</div> </div>
<div style={{ marginTop: 6, color: "var(--muted)", fontSize: 10 }}></div> <div style={{ marginTop: 6, color: "var(--muted)", fontSize: 10 }}></div>
<div style={{ fontSize: 10, color: "var(--text)" }}>{legacyData?.generatedAt ? fmtIsoFull(legacyData.generatedAt) : "loading..."}</div> <div style={{ fontSize: 10, color: "var(--text)" }}>{legacyData?.generatedAt ? fmtLocal(legacyData.generatedAt) : "loading..."}</div>
</div> </div>
)} )}
</div> </div>
@ -701,7 +666,7 @@ export function DashboardPage() {
</div> </div>
<div className="map-area"> <div className="map-area">
{showMapLoader ? ( {isProjectionLoading ? (
<div className="map-loader-overlay" role="status" aria-live="polite"> <div className="map-loader-overlay" role="status" aria-live="polite">
<div className="map-loader-overlay__panel"> <div className="map-loader-overlay__panel">
<div className="map-loader-overlay__spinner" /> <div className="map-loader-overlay__spinner" />
@ -733,8 +698,7 @@ export function DashboardPage() {
fcLinks={fcLinksForMap} fcLinks={fcLinksForMap}
fleetCircles={fleetCirclesForMap} fleetCircles={fleetCirclesForMap}
fleetFocus={fleetFocus} fleetFocus={fleetFocus}
onProjectionLoadingChange={handleProjectionLoadingChange} onProjectionLoadingChange={setIsProjectionLoading}
onGlobeShipsReady={setIsGlobeShipsReady}
onHoverMmsi={(mmsis) => setHoveredMmsiSet(setUniqueSorted(mmsis))} onHoverMmsi={(mmsis) => setHoveredMmsiSet(setUniqueSorted(mmsis))}
onClearMmsiHover={() => setHoveredMmsiSet((prev) => (prev.length === 0 ? prev : []))} onClearMmsiHover={() => setHoveredMmsiSet((prev) => (prev.length === 0 ? prev : []))}
onHoverPair={(pairMmsis) => setHoveredPairMmsiSet(setUniqueSorted(pairMmsis))} onHoverPair={(pairMmsis) => setHoveredPairMmsiSet(setUniqueSorted(pairMmsis))}
@ -747,34 +711,13 @@ export function DashboardPage() {
setHoveredFleetOwnerKey(null); setHoveredFleetOwnerKey(null);
setHoveredFleetMmsiSet((prev) => (prev.length === 0 ? prev : [])); setHoveredFleetMmsiSet((prev) => (prev.length === 0 ? prev : []));
}} }}
subcableGeo={subcableData?.geo ?? null}
hoveredCableId={hoveredCableId}
onHoverCable={setHoveredCableId}
onClickCable={(id) => setSelectedCableId((prev) => (prev === id ? null : id))}
mapStyleSettings={mapStyleSettings}
initialView={mapView}
onViewStateChange={setMapView}
activeTrack={activeTrack}
trackContextMenu={trackContextMenu}
onRequestTrack={handleRequestTrack}
onCloseTrackMenu={handleCloseTrackMenu}
onOpenTrackMenu={handleOpenTrackMenu}
/> />
<MapSettingsPanel value={mapStyleSettings} onChange={setMapStyleSettings} />
<DepthLegend depthStops={mapStyleSettings.depthStops} />
<MapLegend /> <MapLegend />
{selectedLegacyVessel ? ( {selectedLegacyVessel ? (
<VesselInfoPanel vessel={selectedLegacyVessel} allVessels={legacyVesselsAll} onClose={() => setSelectedMmsi(null)} onSelectMmsi={setSelectedMmsi} /> <VesselInfoPanel vessel={selectedLegacyVessel} allVessels={legacyVesselsAll} onClose={() => setSelectedMmsi(null)} onSelectMmsi={setSelectedMmsi} />
) : selectedTarget ? ( ) : selectedTarget ? (
<AisInfoPanel target={selectedTarget} legacy={selectedLegacyInfo} onClose={() => setSelectedMmsi(null)} /> <AisInfoPanel target={selectedTarget} legacy={selectedLegacyInfo} onClose={() => setSelectedMmsi(null)} />
) : null} ) : null}
{selectedCableId && subcableData?.details.get(selectedCableId) ? (
<SubcableInfoPanel
detail={subcableData.details.get(selectedCableId)!}
color={subcableData.geo.features.find((f) => f.properties.id === selectedCableId)?.properties.color}
onClose={() => setSelectedCableId(null)}
/>
) : null}
</div> </div>
</div> </div>
); );

파일 보기

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

파일 보기

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

파일 보기

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

파일 보기

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

파일 보기

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

파일 보기

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

파일 보기

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

파일 보기

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

파일 보기

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

파일 보기

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

파일 보기

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

파일 보기

@ -1,103 +0,0 @@
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];
}

파일 보기

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

파일 보기

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

파일 보기

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

파일 보기

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

파일 보기

@ -1,33 +0,0 @@
import { useMemo } from 'react';
import type { DepthColorStop } from '../../features/mapSettings/types';
interface DepthLegendProps {
depthStops: DepthColorStop[];
}
export function DepthLegend({ depthStops }: DepthLegendProps) {
const sorted = useMemo(
() => [...depthStops].sort((a, b) => a.depth - b.depth),
[depthStops],
);
const gradient = useMemo(() => {
if (sorted.length === 0) return 'transparent';
const stops = sorted.map((s, i) => {
const pct = (i / (sorted.length - 1)) * 100;
return `${s.color} ${pct.toFixed(0)}%`;
});
return `linear-gradient(to bottom, ${stops.join(', ')})`;
}, [sorted]);
return (
<div className="depth-legend">
<div className="depth-legend__bar" style={{ background: gradient }} />
<div className="depth-legend__ticks">
{sorted.map((s) => (
<span key={s.depth}>{Math.abs(s.depth).toLocaleString()}m</span>
))}
</div>
</div>
);
}

파일 보기

@ -25,10 +25,6 @@ import { useGlobeShips } from './hooks/useGlobeShips';
import { useGlobeOverlays } from './hooks/useGlobeOverlays'; import { useGlobeOverlays } from './hooks/useGlobeOverlays';
import { useGlobeInteraction } from './hooks/useGlobeInteraction'; import { useGlobeInteraction } from './hooks/useGlobeInteraction';
import { useDeckLayers } from './hooks/useDeckLayers'; import { useDeckLayers } from './hooks/useDeckLayers';
import { useSubcablesLayer } from './hooks/useSubcablesLayer';
import { useVesselTrackLayer } from './hooks/useVesselTrackLayer';
import { useMapStyleSettings } from './hooks/useMapStyleSettings';
import { VesselContextMenu } from './components/VesselContextMenu';
export type { Map3DSettings, BaseMapId, MapProjectionId } from './types'; export type { Map3DSettings, BaseMapId, MapProjectionId } from './types';
@ -63,19 +59,6 @@ export function Map3D({
onClearMmsiHover, onClearMmsiHover,
onHoverPair, onHoverPair,
onClearPairHover, onClearPairHover,
subcableGeo = null,
hoveredCableId = null,
onHoverCable,
onClickCable,
mapStyleSettings,
initialView,
onViewStateChange,
onGlobeShipsReady,
activeTrack = null,
trackContextMenu = null,
onRequestTrack,
onCloseTrackMenu,
onOpenTrackMenu,
}: Props) { }: Props) {
void onHoverFleet; void onHoverFleet;
void onClearFleetHover; void onClearFleetHover;
@ -101,7 +84,6 @@ export function Map3D({
// ── Hover state ────────────────────────────────────────────────────── // ── Hover state ──────────────────────────────────────────────────────
const { const {
hoveredDeckMmsiSet: hoveredDeckMmsiArr,
setHoveredDeckMmsiSet, setHoveredDeckMmsiSet,
setHoveredDeckPairMmsiSet, setHoveredDeckPairMmsiSet,
setHoveredDeckFleetOwnerKey, setHoveredDeckFleetOwnerKey,
@ -445,15 +427,15 @@ export function Map3D({
}, [fleetCircles, hoveredFleetOwnerKeys, isHighlightedFleet, overlays.fleetCircles, highlightedMmsiSetCombined]); }, [fleetCircles, hoveredFleetOwnerKeys, isHighlightedFleet, overlays.fleetCircles, highlightedMmsiSetCombined]);
// ── Hook orchestration ─────────────────────────────────────────────── // ── Hook orchestration ───────────────────────────────────────────────
const { ensureMercatorOverlay, pulseMapSync } = useMapInit( const { ensureMercatorOverlay, clearGlobeNativeLayers, pulseMapSync } = useMapInit(
containerRef, mapRef, overlayRef, overlayInteractionRef, globeDeckLayerRef, containerRef, mapRef, overlayRef, overlayInteractionRef, globeDeckLayerRef,
baseMapRef, projectionRef, baseMapRef, projectionRef,
{ baseMap, projection, showSeamark: settings.showSeamark, onViewBboxChange, setMapSyncEpoch, initialView, onViewStateChange }, { baseMap, projection, showSeamark: settings.showSeamark, onViewBboxChange, setMapSyncEpoch },
); );
const reorderGlobeFeatureLayers = useProjectionToggle( const reorderGlobeFeatureLayers = useProjectionToggle(
mapRef, overlayRef, overlayInteractionRef, globeDeckLayerRef, projectionBusyRef, mapRef, overlayRef, overlayInteractionRef, globeDeckLayerRef, projectionBusyRef,
{ projection, ensureMercatorOverlay, onProjectionLoadingChange, pulseMapSync, setMapSyncEpoch }, { projection, clearGlobeNativeLayers, ensureMercatorOverlay, onProjectionLoadingChange, pulseMapSync, setMapSyncEpoch },
); );
useBaseMapToggle( useBaseMapToggle(
@ -461,8 +443,6 @@ export function Map3D({
{ baseMap, projection, showSeamark: settings.showSeamark, mapSyncEpoch, pulseMapSync }, { baseMap, projection, showSeamark: settings.showSeamark, mapSyncEpoch, pulseMapSync },
); );
useMapStyleSettings(mapRef, mapStyleSettings, { baseMap, mapSyncEpoch });
useZonesLayer( useZonesLayer(
mapRef, projectionBusyRef, reorderGlobeFeatureLayers, mapRef, projectionBusyRef, reorderGlobeFeatureLayers,
{ zones, overlays, projection, baseMap, hoveredZoneId, mapSyncEpoch }, { zones, overlays, projection, baseMap, hoveredZoneId, mapSyncEpoch },
@ -483,7 +463,6 @@ export function Map3D({
shipOverlayLayerData, shipLayerData, shipByMmsi, mapSyncEpoch, shipOverlayLayerData, shipLayerData, shipByMmsi, mapSyncEpoch,
onSelectMmsi, onToggleHighlightMmsi, targets, overlays, onSelectMmsi, onToggleHighlightMmsi, targets, overlays,
legacyHits, selectedMmsi, isBaseHighlightedMmsi, hasAuxiliarySelectModifier, legacyHits, selectedMmsi, isBaseHighlightedMmsi, hasAuxiliarySelectModifier,
onGlobeShipsReady,
}, },
); );
@ -521,94 +500,10 @@ export function Map3D({
}, },
); );
// eslint-disable-next-line @typescript-eslint/no-unused-vars
const noopCable = useCallback((_: string | null) => {}, []);
useSubcablesLayer(
mapRef, projectionBusyRef, reorderGlobeFeatureLayers,
{
subcableGeo: subcableGeo ?? null,
overlays, projection, mapSyncEpoch,
hoveredCableId: hoveredCableId ?? null,
onHoverCable: onHoverCable ?? noopCable,
onClickCable: onClickCable ?? noopCable,
},
);
useVesselTrackLayer(
mapRef, overlayRef, projectionBusyRef, reorderGlobeFeatureLayers,
{ activeTrack, projection, mapSyncEpoch },
);
// 우클릭 컨텍스트 메뉴 — 대상선박(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-halo', 'ships-globe-outline',
].filter((id) => map.getLayer(id));
let features: maplibregl.MapGeoJSONFeature[] = [];
try {
if (shipLayerIds.length > 0) {
features = map.queryRenderedFeatures(point, { layers: shipLayerIds });
}
} catch { /* ignore */ }
if (features.length > 0) {
const props = features[0].properties || {};
const raw = typeof props.mmsi === 'number' ? props.mmsi : Number(props.mmsi);
if (Number.isFinite(raw) && raw > 0) mmsi = raw;
}
} else {
// Mercator: Deck.gl hover 상태에서 현재 호버된 MMSI 사용
const hovered = hoveredDeckMmsiRef.current;
if (hovered.length > 0) mmsi = hovered[0];
}
if (mmsi == null || !legacyHits?.has(mmsi)) return;
const target = shipByMmsi.get(mmsi);
const vesselName = (target?.name || '').trim() || `MMSI ${mmsi}`;
onOpenTrackMenu({ x: e.clientX, y: e.clientY, mmsi, vesselName });
};
container.addEventListener('contextmenu', onContextMenu);
return () => container.removeEventListener('contextmenu', onContextMenu);
}, [onOpenTrackMenu, legacyHits, shipByMmsi]);
useFlyTo( useFlyTo(
mapRef, projectionRef, mapRef, projectionRef,
{ selectedMmsi, shipData, fleetFocusId, fleetFocusLon, fleetFocusLat, fleetFocusZoom }, { selectedMmsi, shipData, fleetFocusId, fleetFocusLon, fleetFocusLat, fleetFocusZoom },
); );
return ( return <div ref={containerRef} style={{ width: '100%', height: '100%' }} />;
<>
<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}
/>
)}
</>
);
} }

파일 보기

@ -1,135 +0,0 @@
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>
);
}

파일 보기

@ -3,6 +3,7 @@ import {
OVERLAY_RGB, OVERLAY_RGB,
rgba as rgbaCss, rgba as rgbaCss,
} from '../../shared/lib/map/palette'; } from '../../shared/lib/map/palette';
import type { BathyZoomRange } from './types';
// ── Re-export palette aliases used throughout Map3D ── // ── Re-export palette aliases used throughout Map3D ──
@ -157,5 +158,9 @@ export const FLEET_LINE_ML = rgbaCss(OVERLAY_FLEET_RANGE_RGB, 0.65);
export const FLEET_LINE_ML_HL = rgbaCss(OVERLAY_FLEET_RANGE_RGB, 0.95); export const FLEET_LINE_ML_HL = rgbaCss(OVERLAY_FLEET_RANGE_RGB, 0.95);
// ── Bathymetry zoom ranges ── // ── Bathymetry zoom ranges ──
// NOTE: BATHY_ZOOM_RANGES는 bathymetry.ts에서 로컬 정의 + applyBathymetryZoomProfile()에서 사용
// 이 파일의 export는 사용처가 없어 제거됨 (2026-02-16) export const BATHY_ZOOM_RANGES: BathyZoomRange[] = [
{ id: 'bathymetry-fill', mercator: [5, 24], globe: [7, 24] },
{ id: 'bathymetry-borders', mercator: [5, 24], globe: [7, 24] },
{ id: 'bathymetry-borders-major', mercator: [3, 24], globe: [7, 24] },
];

파일 보기

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

파일 보기

@ -50,13 +50,6 @@ import {
getFleetCircleTooltipHtml, getFleetCircleTooltipHtml,
} from '../lib/tooltips'; } from '../lib/tooltips';
import { sanitizeDeckLayerList } from '../lib/mapCore'; import { sanitizeDeckLayerList } from '../lib/mapCore';
import { getCachedShipIcon } from '../lib/shipIconCache';
// NOTE:
// Globe mode now relies on MapLibre native overlays (useGlobeOverlays/useGlobeShips).
// Keep Deck custom-layer rendering disabled in globe to avoid projection-space artifacts.
const ENABLE_GLOBE_DECK_OVERLAYS = false;
export function useDeckLayers( export function useDeckLayers(
mapRef: MutableRefObject<import('maplibre-gl').Map | null>, mapRef: MutableRefObject<import('maplibre-gl').Map | null>,
@ -381,7 +374,7 @@ export function useDeckLayers(
pickable: true, pickable: true,
billboard: false, billboard: false,
parameters: overlayParams, parameters: overlayParams,
iconAtlas: getCachedShipIcon(), iconAtlas: '/assets/ship.svg',
iconMapping: SHIP_ICON_MAPPING, iconMapping: SHIP_ICON_MAPPING,
getIcon: () => 'ship', getIcon: () => 'ship',
getPosition: (d) => [d.lon, d.lat] as [number, number], getPosition: (d) => [d.lon, d.lat] as [number, number],
@ -404,7 +397,7 @@ export function useDeckLayers(
pickable: false, pickable: false,
billboard: false, billboard: false,
parameters: overlayParams, parameters: overlayParams,
iconAtlas: getCachedShipIcon(), iconAtlas: '/assets/ship.svg',
iconMapping: SHIP_ICON_MAPPING, iconMapping: SHIP_ICON_MAPPING,
getIcon: () => 'ship', getIcon: () => 'ship',
getPosition: (d) => [d.lon, d.lat] as [number, number], getPosition: (d) => [d.lon, d.lat] as [number, number],
@ -449,7 +442,7 @@ export function useDeckLayers(
pickable: true, pickable: true,
billboard: false, billboard: false,
parameters: overlayParams, parameters: overlayParams,
iconAtlas: getCachedShipIcon(), iconAtlas: '/assets/ship.svg',
iconMapping: SHIP_ICON_MAPPING, iconMapping: SHIP_ICON_MAPPING,
getIcon: () => 'ship', getIcon: () => 'ship',
getPosition: (d) => [d.lon, d.lat] as [number, number], getPosition: (d) => [d.lon, d.lat] as [number, number],
@ -485,7 +478,7 @@ export function useDeckLayers(
if (settings.showShips && shipOverlayLayerData.filter((t) => legacyHits?.has(t.mmsi)).length > 0) { if (settings.showShips && shipOverlayLayerData.filter((t) => legacyHits?.has(t.mmsi)).length > 0) {
const shipOverlayTargetData2 = shipOverlayLayerData.filter((t) => legacyHits?.has(t.mmsi)); const shipOverlayTargetData2 = shipOverlayLayerData.filter((t) => legacyHits?.has(t.mmsi));
layers.push(new IconLayer<AisTarget>({ id: 'ships-overlay-target', data: shipOverlayTargetData2, pickable: false, billboard: false, parameters: overlayParams, iconAtlas: getCachedShipIcon(), iconMapping: SHIP_ICON_MAPPING, getIcon: () => 'ship', getPosition: (d) => [d.lon, d.lat] as [number, number], getAngle: (d) => -getDisplayHeading({ cog: d.cog, heading: d.heading }), sizeUnits: 'pixels', getSize: (d) => { if (selectedMmsi != null && d.mmsi === selectedMmsi) return FLAT_SHIP_ICON_SIZE_SELECTED; if (shipHighlightSet.has(d.mmsi)) return FLAT_SHIP_ICON_SIZE_HIGHLIGHTED; return 0; }, getColor: (d) => { if (!shipHighlightSet.has(d.mmsi) && !(selectedMmsi != null && d.mmsi === selectedMmsi)) return [0, 0, 0, 0]; return getShipColor(d, selectedMmsi, legacyHits?.get(d.mmsi)?.shipCode ?? null, shipHighlightSet); } })); 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 normalizedLayers = sanitizeDeckLayerList(layers);
@ -602,16 +595,6 @@ export function useDeckLayers(
const deckTarget = globeDeckLayerRef.current; const deckTarget = globeDeckLayerRef.current;
if (!deckTarget) return; if (!deckTarget) return;
if (!ENABLE_GLOBE_DECK_OVERLAYS) {
try {
deckTarget.setProps({ layers: [], getTooltip: undefined, onClick: undefined } as never);
} catch {
// ignore
}
return;
}
const overlayParams = GLOBE_OVERLAY_PARAMS; const overlayParams = GLOBE_OVERLAY_PARAMS;
const globeLayers: unknown[] = []; const globeLayers: unknown[] = [];

파일 보기

@ -118,7 +118,7 @@ export function useGlobeInteraction(
}); });
} }
if (layerId === 'fleet-circles-ml') { if (layerId === 'fleet-circles-ml' || layerId === 'fleet-circles-ml-fill') {
return getFleetCircleTooltipHtml({ return getFleetCircleTooltipHtml({
ownerKey: String(props.ownerKey ?? ''), ownerKey: String(props.ownerKey ?? ''),
ownerLabel: String(props.ownerLabel ?? props.ownerKey ?? ''), ownerLabel: String(props.ownerLabel ?? props.ownerKey ?? ''),
@ -186,7 +186,7 @@ export function useGlobeInteraction(
candidateLayerIds = [ candidateLayerIds = [
'ships-globe', 'ships-globe-halo', 'ships-globe-outline', 'ships-globe', 'ships-globe-halo', 'ships-globe-outline',
'pair-lines-ml', 'fc-lines-ml', 'pair-lines-ml', 'fc-lines-ml',
'fleet-circles-ml', 'fleet-circles-ml', 'fleet-circles-ml-fill',
'pair-range-ml', 'pair-range-ml',
'zones-fill', 'zones-line', 'zones-label', 'zones-fill', 'zones-line', 'zones-label',
].filter((id) => map.getLayer(id)); ].filter((id) => map.getLayer(id));
@ -213,7 +213,7 @@ export function useGlobeInteraction(
const priority = [ const priority = [
'ships-globe', 'ships-globe-halo', 'ships-globe-outline', 'ships-globe', 'ships-globe-halo', 'ships-globe-outline',
'pair-lines-ml', 'fc-lines-ml', 'pair-range-ml', 'pair-lines-ml', 'fc-lines-ml', 'pair-range-ml',
'fleet-circles-ml', 'fleet-circles-ml-fill', 'fleet-circles-ml',
'zones-fill', 'zones-line', 'zones-label', 'zones-fill', 'zones-line', 'zones-label',
]; ];
@ -232,7 +232,7 @@ export function useGlobeInteraction(
const isShipLayer = layerId === 'ships-globe' || layerId === 'ships-globe-halo' || layerId === 'ships-globe-outline'; const isShipLayer = layerId === 'ships-globe' || layerId === 'ships-globe-halo' || layerId === 'ships-globe-outline';
const isPairLayer = layerId === 'pair-lines-ml' || layerId === 'pair-range-ml'; const isPairLayer = layerId === 'pair-lines-ml' || layerId === 'pair-range-ml';
const isFcLayer = layerId === 'fc-lines-ml'; const isFcLayer = layerId === 'fc-lines-ml';
const isFleetLayer = layerId === 'fleet-circles-ml'; const isFleetLayer = layerId === 'fleet-circles-ml' || layerId === 'fleet-circles-ml-fill';
const isZoneLayer = layerId === 'zones-fill' || layerId === 'zones-line' || layerId === 'zones-label'; const isZoneLayer = layerId === 'zones-fill' || layerId === 'zones-line' || layerId === 'zones-label';
if (isShipLayer) { if (isShipLayer) {

파일 보기

@ -11,6 +11,7 @@ import {
PAIR_RANGE_NORMAL_ML_HL, PAIR_RANGE_WARN_ML_HL, PAIR_RANGE_NORMAL_ML_HL, PAIR_RANGE_WARN_ML_HL,
FC_LINE_NORMAL_ML, FC_LINE_SUSPICIOUS_ML, FC_LINE_NORMAL_ML, FC_LINE_SUSPICIOUS_ML,
FC_LINE_NORMAL_ML_HL, FC_LINE_SUSPICIOUS_ML_HL, FC_LINE_NORMAL_ML_HL, FC_LINE_SUSPICIOUS_ML_HL,
FLEET_FILL_ML, FLEET_FILL_ML_HL,
FLEET_LINE_ML, FLEET_LINE_ML_HL, FLEET_LINE_ML, FLEET_LINE_ML_HL,
} from '../constants'; } from '../constants';
import { makeUniqueSorted } from '../lib/setUtils'; import { makeUniqueSorted } from '../lib/setUtils';
@ -27,7 +28,6 @@ import {
} from '../lib/mlExpressions'; } from '../lib/mlExpressions';
import { kickRepaint, onMapStyleReady } from '../lib/mapCore'; import { kickRepaint, onMapStyleReady } from '../lib/mapCore';
import { circleRingLngLat } from '../lib/geometry'; import { circleRingLngLat } from '../lib/geometry';
import { guardedSetVisibility } from '../lib/layerHelpers';
import { dashifyLine } from '../lib/dashifyLine'; import { dashifyLine } from '../lib/dashifyLine';
export function useGlobeOverlays( export function useGlobeOverlays(
@ -60,7 +60,11 @@ export function useGlobeOverlays(
const layerId = 'pair-lines-ml'; const layerId = 'pair-lines-ml';
const remove = () => { const remove = () => {
guardedSetVisibility(map, layerId, 'none'); try {
if (map.getLayer(layerId)) map.setLayoutProperty(layerId, 'visibility', 'none');
} catch {
// ignore
}
}; };
const ensure = () => { const ensure = () => {
@ -128,7 +132,11 @@ export function useGlobeOverlays(
console.warn('Pair lines layer add failed:', e); console.warn('Pair lines layer add failed:', e);
} }
} else { } else {
guardedSetVisibility(map, layerId, 'visible'); try {
map.setLayoutProperty(layerId, 'visibility', 'visible');
} catch {
// ignore
}
} }
reorderGlobeFeatureLayers(); reorderGlobeFeatureLayers();
@ -151,7 +159,11 @@ export function useGlobeOverlays(
const layerId = 'fc-lines-ml'; const layerId = 'fc-lines-ml';
const remove = () => { const remove = () => {
guardedSetVisibility(map, layerId, 'none'); try {
if (map.getLayer(layerId)) map.setLayoutProperty(layerId, 'visibility', 'none');
} catch {
// ignore
}
}; };
const ensure = () => { const ensure = () => {
@ -223,7 +235,11 @@ export function useGlobeOverlays(
console.warn('FC lines layer add failed:', e); console.warn('FC lines layer add failed:', e);
} }
} else { } else {
guardedSetVisibility(map, layerId, 'visible'); try {
map.setLayoutProperty(layerId, 'visibility', 'visible');
} catch {
// ignore
}
} }
reorderGlobeFeatureLayers(); reorderGlobeFeatureLayers();
@ -243,13 +259,21 @@ export function useGlobeOverlays(
if (!map) return; if (!map) return;
const srcId = 'fleet-circles-ml-src'; const srcId = 'fleet-circles-ml-src';
const fillSrcId = 'fleet-circles-ml-fill-src';
const layerId = 'fleet-circles-ml'; const layerId = 'fleet-circles-ml';
const fillLayerId = 'fleet-circles-ml-fill';
// fill layer 제거됨 — globe tessellation에서 vertex 65535 초과 경고 원인
// 라인만으로 fleet circle 시각화 충분
const remove = () => { const remove = () => {
guardedSetVisibility(map, layerId, 'none'); 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 = () => { const ensure = () => {
@ -280,6 +304,26 @@ export function useGlobeOverlays(
}), }),
}; };
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 { try {
const existing = map.getSource(srcId) as GeoJSONSource | undefined; const existing = map.getSource(srcId) as GeoJSONSource | undefined;
if (existing) existing.setData(fcLine); if (existing) existing.setData(fcLine);
@ -289,6 +333,41 @@ export function useGlobeOverlays(
return; 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)) { if (!map.getLayer(layerId)) {
try { try {
map.addLayer( map.addLayer(
@ -309,7 +388,11 @@ export function useGlobeOverlays(
console.warn('Fleet circles layer add failed:', e); console.warn('Fleet circles layer add failed:', e);
} }
} else { } else {
guardedSetVisibility(map, layerId, 'visible'); try {
map.setLayoutProperty(layerId, 'visibility', 'visible');
} catch {
// ignore
}
} }
reorderGlobeFeatureLayers(); reorderGlobeFeatureLayers();
@ -332,7 +415,11 @@ export function useGlobeOverlays(
const layerId = 'pair-range-ml'; const layerId = 'pair-range-ml';
const remove = () => { const remove = () => {
guardedSetVisibility(map, layerId, 'none'); try {
if (map.getLayer(layerId)) map.setLayoutProperty(layerId, 'visibility', 'none');
} catch {
// ignore
}
}; };
const ensure = () => { const ensure = () => {
@ -416,7 +503,11 @@ export function useGlobeOverlays(
console.warn('Pair range layer add failed:', e); console.warn('Pair range layer add failed:', e);
} }
} else { } else {
guardedSetVisibility(map, layerId, 'visible'); try {
map.setLayoutProperty(layerId, 'visibility', 'visible');
} catch {
// ignore
}
} }
kickRepaint(map); kickRepaint(map);
@ -502,7 +593,10 @@ export function useGlobeOverlays(
} }
try { try {
// fleet-circles-ml-fill 제거됨 (vertex 65535 경고 원인) 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')) { if (map.getLayer('fleet-circles-ml')) {
map.setPaintProperty('fleet-circles-ml', 'line-color', ['case', fleetHighlightExpr, FLEET_LINE_ML_HL, FLEET_LINE_ML] as never); map.setPaintProperty('fleet-circles-ml', 'line-color', ['case', fleetHighlightExpr, FLEET_LINE_ML_HL, FLEET_LINE_ML] as never);
map.setPaintProperty('fleet-circles-ml', 'line-width', ['case', fleetHighlightExpr, 2, 1.1] as never); map.setPaintProperty('fleet-circles-ml', 'line-width', ['case', fleetHighlightExpr, 2, 1.1] as never);

파일 보기

@ -1,4 +1,4 @@
import { useEffect, useMemo, useRef, type MutableRefObject } from 'react'; import { useEffect, useRef, type MutableRefObject } from 'react';
import type maplibregl from 'maplibre-gl'; import type maplibregl from 'maplibre-gl';
import type { GeoJSONSource, GeoJSONSourceSpecification, LayerSpecification } from 'maplibre-gl'; import type { GeoJSONSource, GeoJSONSourceSpecification, LayerSpecification } from 'maplibre-gl';
import type { AisTarget } from '../../../entities/aisTarget/model/types'; import type { AisTarget } from '../../../entities/aisTarget/model/types';
@ -25,7 +25,6 @@ import {
ensureFallbackShipImage, ensureFallbackShipImage,
} from '../lib/globeShipIcon'; } from '../lib/globeShipIcon';
import { clampNumber } from '../lib/geometry'; import { clampNumber } from '../lib/geometry';
import { guardedSetVisibility } from '../lib/layerHelpers';
export function useGlobeShips( export function useGlobeShips(
mapRef: MutableRefObject<maplibregl.Map | null>, mapRef: MutableRefObject<maplibregl.Map | null>,
@ -49,81 +48,18 @@ export function useGlobeShips(
selectedMmsi: number | null; selectedMmsi: number | null;
isBaseHighlightedMmsi: (mmsi: number) => boolean; isBaseHighlightedMmsi: (mmsi: number) => boolean;
hasAuxiliarySelectModifier: (ev?: { shiftKey?: boolean; ctrlKey?: boolean; metaKey?: boolean } | null) => boolean; hasAuxiliarySelectModifier: (ev?: { shiftKey?: boolean; ctrlKey?: boolean; metaKey?: boolean } | null) => boolean;
onGlobeShipsReady?: (ready: boolean) => void;
}, },
) { ) {
const { const {
projection, settings, shipData, shipHighlightSet, shipHoverOverlaySet, projection, settings, shipData, shipHighlightSet, shipHoverOverlaySet,
shipLayerData, mapSyncEpoch, onSelectMmsi, onToggleHighlightMmsi, targets, shipLayerData, mapSyncEpoch, onSelectMmsi, onToggleHighlightMmsi, targets,
overlays, legacyHits, selectedMmsi, isBaseHighlightedMmsi, hasAuxiliarySelectModifier, overlays, legacyHits, selectedMmsi, isBaseHighlightedMmsi, hasAuxiliarySelectModifier,
onGlobeShipsReady,
} = opts; } = opts;
const globeShipsEpochRef = useRef(-1); const globeShipsEpochRef = useRef(-1);
const globeShipIconLoadingRef = useRef(false);
const globeHoverShipSignatureRef = useRef(''); const globeHoverShipSignatureRef = useRef('');
// Globe GeoJSON을 projection과 무관하게 항상 사전 계산
// Globe 전환 시 즉시 사용 가능하도록 useMemo로 캐싱
const globeShipGeoJson = useMemo((): GeoJSON.FeatureCollection<GeoJSON.Point> => {
return {
type: 'FeatureCollection',
features: shipData.map((t) => {
const legacy = legacyHits?.get(t.mmsi) ?? null;
const labelName = legacy?.shipNameCn || legacy?.shipNameRoman || t.name || '';
const heading = getDisplayHeading({
cog: t.cog,
heading: t.heading,
offset: GLOBE_ICON_HEADING_OFFSET_DEG,
});
const isAnchored = isAnchoredShip({ sog: t.sog, cog: t.cog, heading: t.heading });
const shipHeading = isAnchored ? 0 : heading;
const hull = clampNumber(
(isFiniteNumber(t.length) ? t.length : 0) + (isFiniteNumber(t.width) ? t.width : 0) * 3,
50, 420,
);
const sizeScale = clampNumber(0.85 + (hull - 50) / 420, 0.82, 1.85);
const selected = t.mmsi === selectedMmsi;
const highlighted = isBaseHighlightedMmsi(t.mmsi);
const selectedScale = selected ? 1.08 : 1;
const highlightScale = highlighted ? 1.06 : 1;
const iconScale = selected ? selectedScale : highlightScale;
const iconSize3 = clampNumber(0.35 * sizeScale * selectedScale, 0.25, 1.3);
const iconSize7 = clampNumber(0.45 * sizeScale * selectedScale, 0.3, 1.45);
const iconSize10 = clampNumber(0.58 * sizeScale * selectedScale, 0.35, 1.8);
const iconSize14 = clampNumber(0.85 * sizeScale * selectedScale, 0.45, 2.6);
const iconSize18 = clampNumber(2.5 * sizeScale * selectedScale, 1.0, 6.0);
return {
type: 'Feature' as const,
...(isFiniteNumber(t.mmsi) ? { id: Math.trunc(t.mmsi) } : {}),
geometry: { type: 'Point' as const, coordinates: [t.lon, t.lat] },
properties: {
mmsi: t.mmsi,
name: t.name || '',
labelName,
cog: shipHeading,
heading: shipHeading,
sog: isFiniteNumber(t.sog) ? t.sog : 0,
isAnchored: isAnchored ? 1 : 0,
shipColor: getGlobeBaseShipColor({
legacy: legacy?.shipCode || null,
sog: isFiniteNumber(t.sog) ? t.sog : null,
}),
iconSize3: iconSize3 * iconScale,
iconSize7: iconSize7 * iconScale,
iconSize10: iconSize10 * iconScale,
iconSize14: iconSize14 * iconScale,
iconSize18: iconSize18 * iconScale,
sizeScale,
selected: selected ? 1 : 0,
highlighted: highlighted ? 1 : 0,
permitted: legacy ? 1 : 0,
code: legacy?.shipCode || '',
},
};
}),
};
}, [shipData, legacyHits, selectedMmsi, isBaseHighlightedMmsi]);
// Ship name labels in mercator // Ship name labels in mercator
useEffect(() => { useEffect(() => {
const map = mapRef.current; const map = mapRef.current;
@ -273,52 +209,110 @@ export function useGlobeShips(
const symbolId = 'ships-globe'; const symbolId = 'ships-globe';
const labelId = 'ships-globe-label'; const labelId = 'ships-globe-label';
// 레이어를 제거하지 않고 visibility만 'none'으로 설정 const remove = () => {
// guardedSetVisibility로 현재 값과 동일하면 호출 생략 (style._changed 방지)
const hide = () => {
for (const id of [labelId, symbolId, outlineId, haloId]) { for (const id of [labelId, symbolId, outlineId, haloId]) {
guardedSetVisibility(map, id, 'none'); try {
if (map.getLayer(id)) map.removeLayer(id);
} catch {
// ignore
}
} }
try {
if (map.getSource(srcId)) map.removeSource(srcId);
} catch {
// ignore
}
globeHoverShipSignatureRef.current = '';
reorderGlobeFeatureLayers();
kickRepaint(map);
}; };
const ensureImage = () => { const ensureImage = () => {
ensureFallbackShipImage(map, imgId); ensureFallbackShipImage(map, imgId);
ensureFallbackShipImage(map, anchoredImgId, buildFallbackGlobeAnchoredShipIcon); ensureFallbackShipImage(map, anchoredImgId, buildFallbackGlobeAnchoredShipIcon);
if (globeShipIconLoadingRef.current) return;
if (map.hasImage(imgId) && map.hasImage(anchoredImgId)) return; if (map.hasImage(imgId) && map.hasImage(anchoredImgId)) return;
kickRepaint(map);
const addFallbackImage = () => {
ensureFallbackShipImage(map, imgId);
ensureFallbackShipImage(map, anchoredImgId, buildFallbackGlobeAnchoredShipIcon);
kickRepaint(map);
};
let fallbackTimer: ReturnType<typeof window.setTimeout> | null = null;
try {
globeShipIconLoadingRef.current = true;
fallbackTimer = window.setTimeout(() => {
addFallbackImage();
}, 80);
void map
.loadImage('/assets/ship.svg')
.then((response) => {
globeShipIconLoadingRef.current = false;
if (fallbackTimer != null) {
clearTimeout(fallbackTimer);
fallbackTimer = null;
}
const loadedImage = (response as { data?: HTMLImageElement | ImageBitmap } | undefined)?.data;
if (!loadedImage) {
addFallbackImage();
return;
}
try {
if (map.hasImage(imgId)) {
try {
map.removeImage(imgId);
} catch {
// ignore
}
}
if (map.hasImage(anchoredImgId)) {
try {
map.removeImage(anchoredImgId);
} catch {
// ignore
}
}
map.addImage(imgId, loadedImage, { pixelRatio: 2, sdf: true });
map.addImage(anchoredImgId, loadedImage, { pixelRatio: 2, sdf: true });
kickRepaint(map);
} catch (e) {
console.warn('Ship icon image add failed:', e);
}
})
.catch(() => {
globeShipIconLoadingRef.current = false;
if (fallbackTimer != null) {
clearTimeout(fallbackTimer);
fallbackTimer = null;
}
addFallbackImage();
});
} catch (e) {
globeShipIconLoadingRef.current = false;
if (fallbackTimer != null) {
clearTimeout(fallbackTimer);
fallbackTimer = null;
}
try {
addFallbackImage();
} catch (fallbackError) {
console.warn('Ship icon image setup failed:', e, fallbackError);
}
}
}; };
const ensure = () => { const ensure = () => {
if (!settings.showShips) { if (projectionBusyRef.current) return;
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)) {
const changed = map.getLayoutProperty(symbolId, 'visibility') !== visibility;
if (changed) {
for (const id of [haloId, outlineId, 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 (!map.isStyleLoaded()) return;
if (projection !== 'globe' || !settings.showShips) {
remove();
return;
}
if (globeShipsEpochRef.current !== mapSyncEpoch) { if (globeShipsEpochRef.current !== mapSyncEpoch) {
globeShipsEpochRef.current = mapSyncEpoch; globeShipsEpochRef.current = mapSyncEpoch;
} }
@ -329,8 +323,69 @@ export function useGlobeShips(
console.warn('Ship icon image setup failed:', e); console.warn('Ship icon image setup failed:', e);
} }
// 사전 계산된 GeoJSON 사용 (useMemo에서 projection과 무관하게 항상 준비됨) const globeShipData = shipData;
const geojson = globeShipGeoJson; const geojson: GeoJSON.FeatureCollection<GeoJSON.Point> = {
type: 'FeatureCollection',
features: globeShipData.map((t) => {
const legacy = legacyHits?.get(t.mmsi) ?? null;
const labelName =
legacy?.shipNameCn ||
legacy?.shipNameRoman ||
t.name ||
'';
const heading = getDisplayHeading({
cog: t.cog,
heading: t.heading,
offset: GLOBE_ICON_HEADING_OFFSET_DEG,
});
const isAnchored = isAnchoredShip({
sog: t.sog,
cog: t.cog,
heading: t.heading,
});
const shipHeading = isAnchored ? 0 : heading;
const hull = clampNumber((isFiniteNumber(t.length) ? t.length : 0) + (isFiniteNumber(t.width) ? t.width : 0) * 3, 50, 420);
const sizeScale = clampNumber(0.85 + (hull - 50) / 420, 0.82, 1.85);
const selected = t.mmsi === selectedMmsi;
const highlighted = isBaseHighlightedMmsi(t.mmsi);
const selectedScale = selected ? 1.08 : 1;
const highlightScale = highlighted ? 1.06 : 1;
const iconScale = selected ? selectedScale : highlightScale;
const iconSize3 = clampNumber(0.35 * sizeScale * selectedScale, 0.25, 1.3);
const iconSize7 = clampNumber(0.45 * sizeScale * selectedScale, 0.3, 1.45);
const iconSize10 = clampNumber(0.58 * sizeScale * selectedScale, 0.35, 1.8);
const iconSize14 = clampNumber(0.85 * sizeScale * selectedScale, 0.45, 2.6);
const iconSize18 = clampNumber(2.5 * sizeScale * selectedScale, 1.0, 6.0);
return {
type: 'Feature',
...(isFiniteNumber(t.mmsi) ? { id: Math.trunc(t.mmsi) } : {}),
geometry: { type: 'Point', coordinates: [t.lon, t.lat] },
properties: {
mmsi: t.mmsi,
name: t.name || '',
labelName,
cog: shipHeading,
heading: shipHeading,
sog: isFiniteNumber(t.sog) ? t.sog : 0,
isAnchored: isAnchored ? 1 : 0,
shipColor: getGlobeBaseShipColor({
legacy: legacy?.shipCode || null,
sog: isFiniteNumber(t.sog) ? t.sog : null,
}),
iconSize3: iconSize3 * iconScale,
iconSize7: iconSize7 * iconScale,
iconSize10: iconSize10 * iconScale,
iconSize14: iconSize14 * iconScale,
iconSize18: iconSize18 * iconScale,
sizeScale,
selected: selected ? 1 : 0,
highlighted: highlighted ? 1 : 0,
permitted: legacy ? 1 : 0,
code: legacy?.shipCode || '',
},
};
}),
};
try { try {
const existing = map.getSource(srcId) as GeoJSONSource | undefined; const existing = map.getSource(srcId) as GeoJSONSource | undefined;
@ -341,6 +396,7 @@ export function useGlobeShips(
return; return;
} }
const visibility = settings.showShips ? 'visible' : 'none';
const before = undefined; const before = undefined;
if (!map.getLayer(haloId)) { if (!map.getLayer(haloId)) {
@ -378,8 +434,35 @@ export function useGlobeShips(
} catch (e) { } catch (e) {
console.warn('Ship halo layer add failed:', e); console.warn('Ship halo layer add failed:', e);
} }
} else {
try {
map.setLayoutProperty(haloId, 'visibility', visibility);
map.setLayoutProperty(haloId, 'circle-sort-key', [
'case',
['all', ['==', ['get', 'selected'], 1], ['==', ['get', 'permitted'], 1]], 120,
['all', ['==', ['get', 'highlighted'], 1], ['==', ['get', 'permitted'], 1]], 115,
['==', ['get', 'permitted'], 1], 110,
['==', ['get', 'selected'], 1], 60,
['==', ['get', 'highlighted'], 1], 55,
20,
] as never);
map.setPaintProperty(haloId, 'circle-color', [
'case',
['==', ['get', 'selected'], 1], 'rgba(14,234,255,1)',
['==', ['get', 'highlighted'], 1], 'rgba(245,158,11,1)',
['coalesce', ['get', 'shipColor'], '#64748b'],
] as never);
map.setPaintProperty(haloId, 'circle-opacity', [
'case',
['==', ['get', 'selected'], 1], 0.38,
['==', ['get', 'highlighted'], 1], 0.34,
0.16,
] as never);
map.setPaintProperty(haloId, 'circle-radius', GLOBE_SHIP_CIRCLE_RADIUS_EXPR);
} catch {
// ignore
}
} }
// halo: data-driven expressions are static — visibility handled by fast toggle above
if (!map.getLayer(outlineId)) { if (!map.getLayer(outlineId)) {
try { try {
@ -425,8 +508,36 @@ export function useGlobeShips(
} catch (e) { } catch (e) {
console.warn('Ship outline layer add failed:', e); console.warn('Ship outline layer add failed:', e);
} }
} else {
try {
map.setLayoutProperty(outlineId, 'visibility', visibility);
map.setLayoutProperty(outlineId, '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);
map.setPaintProperty(outlineId, '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);
map.setPaintProperty(outlineId, 'circle-stroke-width', [
'case',
['==', ['get', 'selected'], 1], 3.4,
['==', ['get', 'highlighted'], 1], 2.7,
['==', ['get', 'permitted'], 1], 1.8,
0.7,
] as never);
} catch {
// ignore
}
} }
// outline: data-driven expressions are static — visibility handled by fast toggle
if (!map.getLayer(symbolId)) { if (!map.getLayer(symbolId)) {
try { try {
@ -487,9 +598,31 @@ export function useGlobeShips(
} catch (e) { } catch (e) {
console.warn('Ship symbol layer add failed:', e); console.warn('Ship symbol layer add failed:', e);
} }
} else {
try {
map.setLayoutProperty(symbolId, 'visibility', visibility);
map.setLayoutProperty(symbolId, 'symbol-sort-key', [
'case',
['all', ['==', ['get', 'selected'], 1], ['==', ['get', 'permitted'], 1]], 140,
['all', ['==', ['get', 'highlighted'], 1], ['==', ['get', 'permitted'], 1]], 135,
['==', ['get', 'permitted'], 1], 130,
['==', ['get', 'selected'], 1], 80,
['==', ['get', 'highlighted'], 1], 75,
45,
] as never);
map.setPaintProperty(symbolId, 'icon-opacity', [
'case',
['==', ['get', 'permitted'], 1], 1,
['==', ['get', 'selected'], 1], 0.86,
['==', ['get', 'highlighted'], 1], 0.82,
0.66,
] as never);
} catch {
// ignore
}
} }
// symbol: data-driven expressions are static — visibility handled by fast toggle
const labelVisibility = overlays.shipLabels ? 'visible' : 'none';
const labelFilter = [ const labelFilter = [
'all', 'all',
['!=', ['to-string', ['coalesce', ['get', 'labelName'], '']], ''], ['!=', ['to-string', ['coalesce', ['get', 'labelName'], '']], ''],
@ -539,14 +672,17 @@ export function useGlobeShips(
} catch (e) { } catch (e) {
console.warn('Ship label layer add failed:', e); console.warn('Ship label layer add failed:', e);
} }
} else {
try {
map.setLayoutProperty(labelId, 'visibility', labelVisibility);
map.setFilter(labelId, labelFilter as never);
map.setLayoutProperty(labelId, 'text-field', ['get', 'labelName'] as never);
} catch {
// ignore
}
} }
// label: filter/text-field are static — visibility handled by fast toggle
// 레이어가 준비되었음을 알림 (projection과 무관 — 토글 버튼 활성화용) reorderGlobeFeatureLayers();
onGlobeShipsReady?.(true);
if (projection === 'globe') {
reorderGlobeFeatureLayers();
}
kickRepaint(map); kickRepaint(map);
}; };
@ -558,12 +694,12 @@ export function useGlobeShips(
projection, projection,
settings.showShips, settings.showShips,
overlays.shipLabels, overlays.shipLabels,
globeShipGeoJson, shipData,
legacyHits,
selectedMmsi, selectedMmsi,
isBaseHighlightedMmsi, isBaseHighlightedMmsi,
mapSyncEpoch, mapSyncEpoch,
reorderGlobeFeatureLayers, reorderGlobeFeatureLayers,
onGlobeShipsReady,
]); ]);
// Globe hover overlay ships // Globe hover overlay ships
@ -577,10 +713,22 @@ export function useGlobeShips(
const outlineId = 'ships-globe-hover-outline'; const outlineId = 'ships-globe-hover-outline';
const symbolId = 'ships-globe-hover'; const symbolId = 'ships-globe-hover';
const hideHover = () => { const remove = () => {
for (const id of [symbolId, outlineId, haloId]) { for (const id of [symbolId, outlineId, haloId]) {
guardedSetVisibility(map, id, 'none'); try {
if (map.getLayer(id)) map.removeLayer(id);
} catch {
// ignore
}
} }
try {
if (map.getSource(srcId)) map.removeSource(srcId);
} catch {
// ignore
}
globeHoverShipSignatureRef.current = '';
reorderGlobeFeatureLayers();
kickRepaint(map);
}; };
const ensure = () => { const ensure = () => {
@ -588,7 +736,7 @@ export function useGlobeShips(
if (!map.isStyleLoaded()) return; if (!map.isStyleLoaded()) return;
if (projection !== 'globe' || !settings.showShips || shipHoverOverlaySet.size === 0) { if (projection !== 'globe' || !settings.showShips || shipHoverOverlaySet.size === 0) {
hideHover(); remove();
return; return;
} }
@ -603,7 +751,7 @@ export function useGlobeShips(
const hovered = shipLayerData.filter((t) => shipHoverOverlaySet.has(t.mmsi) && !!legacyHits?.has(t.mmsi)); const hovered = shipLayerData.filter((t) => shipHoverOverlaySet.has(t.mmsi) && !!legacyHits?.has(t.mmsi));
if (hovered.length === 0) { if (hovered.length === 0) {
hideHover(); remove();
return; return;
} }
const hoverSignature = hovered const hoverSignature = hovered

파일 보기

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

파일 보기

@ -1,182 +0,0 @@
import { useEffect, useRef, type MutableRefObject } from 'react';
import maplibregl from 'maplibre-gl';
import type { MapStyleSettings, MapLabelLanguage, DepthColorStop, DepthFontSize } from '../../../features/mapSettings/types';
import type { BaseMapId } from '../types';
import { kickRepaint, onMapStyleReady } from '../lib/mapCore';
/* ── Depth font size presets ──────────────────────────────────────── */
const DEPTH_FONT_SIZE_MAP: Record<DepthFontSize, unknown[]> = {
small: ['interpolate', ['linear'], ['zoom'], 7, 8, 9, 9, 11, 11, 13, 13],
medium: ['interpolate', ['linear'], ['zoom'], 7, 10, 9, 12, 11, 14, 13, 16],
large: ['interpolate', ['linear'], ['zoom'], 7, 12, 9, 15, 11, 18, 13, 20],
};
/* ── Helpers ──────────────────────────────────────────────────────── */
function darkenHex(hex: string, factor = 0.85): string {
const r = parseInt(hex.slice(1, 3), 16);
const g = parseInt(hex.slice(3, 5), 16);
const b = parseInt(hex.slice(5, 7), 16);
return `#${[r, g, b].map((c) => Math.round(c * factor).toString(16).padStart(2, '0')).join('')}`;
}
function lightenHex(hex: string, factor = 1.3): string {
const r = parseInt(hex.slice(1, 3), 16);
const g = parseInt(hex.slice(3, 5), 16);
const b = parseInt(hex.slice(5, 7), 16);
return `#${[r, g, b].map((c) => Math.min(255, Math.round(c * factor)).toString(16).padStart(2, '0')).join('')}`;
}
/* ── Apply functions ──────────────────────────────────────────────── */
function applyLabelLanguage(map: maplibregl.Map, lang: MapLabelLanguage) {
const style = map.getStyle();
if (!style?.layers) return;
for (const layer of style.layers) {
if (layer.type !== 'symbol') continue;
const layout = (layer as { layout?: Record<string, unknown> }).layout;
if (!layout?.['text-field']) continue;
if (layer.id.startsWith('bathymetry-labels')) continue;
const textField =
lang === 'local'
? ['get', 'name']
: ['coalesce', ['get', `name:${lang}`], ['get', 'name']];
try {
map.setLayoutProperty(layer.id, 'text-field', textField);
} catch {
// ignore
}
}
}
function applyLandColor(map: maplibregl.Map, color: string) {
const style = map.getStyle();
if (!style?.layers) return;
const waterRegex = /(water|sea|ocean|river|lake|coast|bay)/i;
const darkVariant = darkenHex(color, 0.8);
for (const layer of style.layers) {
const id = layer.id;
if (id.startsWith('bathymetry-')) continue;
if (id.startsWith('subcables-')) continue;
if (id.startsWith('zones-')) continue;
if (id.startsWith('ships-')) continue;
if (id.startsWith('pair-')) continue;
if (id.startsWith('fc-')) continue;
if (id.startsWith('fleet-')) continue;
if (id.startsWith('predict-')) continue;
if (id.startsWith('vessel-track-')) continue;
if (id === 'deck-globe') continue;
const sourceLayer = String((layer as Record<string, unknown>)['source-layer'] ?? '');
const isWater = waterRegex.test(id) || waterRegex.test(sourceLayer);
if (isWater) continue;
try {
if (layer.type === 'background') {
map.setPaintProperty(id, 'background-color', color);
} else if (layer.type === 'fill') {
map.setPaintProperty(id, 'fill-color', darkVariant);
}
} catch {
// ignore
}
}
}
function applyWaterBaseColor(map: maplibregl.Map, fillColor: string) {
const style = map.getStyle();
if (!style?.layers) return;
const waterRegex = /(water|sea|ocean|river|lake|coast|bay)/i;
const lineColor = darkenHex(fillColor, 0.85);
for (const layer of style.layers) {
const id = layer.id;
if (id.startsWith('bathymetry-')) continue;
const sourceLayer = String((layer as Record<string, unknown>)['source-layer'] ?? '');
if (!waterRegex.test(id) && !waterRegex.test(sourceLayer)) continue;
try {
if (layer.type === 'fill') {
map.setPaintProperty(id, 'fill-color', fillColor);
} else if (layer.type === 'line') {
map.setPaintProperty(id, 'line-color', lineColor);
}
} catch {
// ignore
}
}
}
function applyDepthGradient(map: maplibregl.Map, stops: DepthColorStop[]) {
const depth = ['to-number', ['get', 'depth']];
const sorted = [...stops].sort((a, b) => a.depth - b.depth);
if (sorted.length === 0) return;
const expr: unknown[] = ['interpolate', ['linear'], depth];
for (const s of sorted) {
expr.push(s.depth, s.color);
}
// 0m까지 확장 (최천층 stop이 0보다 깊으면)
const shallowest = sorted[sorted.length - 1];
if (shallowest.depth < 0) {
expr.push(0, lightenHex(shallowest.color, 1.8));
}
if (!map.getLayer('bathymetry-fill')) return;
try {
map.setPaintProperty('bathymetry-fill', 'fill-color', expr as never);
} catch {
// ignore
}
}
function applyDepthFontSize(map: maplibregl.Map, size: DepthFontSize) {
const expr = DEPTH_FONT_SIZE_MAP[size];
for (const layerId of ['bathymetry-labels', 'bathymetry-labels-coarse', 'bathymetry-landforms']) {
if (!map.getLayer(layerId)) continue;
try {
map.setLayoutProperty(layerId, 'text-size', expr);
} catch {
// ignore
}
}
}
function applyDepthFontColor(map: maplibregl.Map, color: string) {
for (const layerId of ['bathymetry-labels', 'bathymetry-labels-coarse', 'bathymetry-landforms']) {
if (!map.getLayer(layerId)) continue;
try {
map.setPaintProperty(layerId, 'text-color', color);
} catch {
// ignore
}
}
}
/* ── Hook ──────────────────────────────────────────────────────────── */
export function useMapStyleSettings(
mapRef: MutableRefObject<maplibregl.Map | null>,
settings: MapStyleSettings | undefined,
opts: { baseMap: BaseMapId; mapSyncEpoch: number },
) {
const settingsRef = useRef(settings);
useEffect(() => {
settingsRef.current = settings;
});
const { baseMap, mapSyncEpoch } = opts;
useEffect(() => {
const map = mapRef.current;
const s = settingsRef.current;
if (!map || !s) return;
const stop = onMapStyleReady(map, () => {
applyLabelLanguage(map, s.labelLanguage);
applyLandColor(map, s.landColor);
applyWaterBaseColor(map, s.waterBaseColor);
if (baseMap === 'enhanced') {
applyDepthGradient(map, s.depthStops);
applyDepthFontSize(map, s.depthFontSize);
applyDepthFontColor(map, s.depthFontColor);
}
kickRepaint(map);
});
return () => stop();
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [settings, baseMap, mapSyncEpoch]);
}

파일 보기

@ -1,169 +0,0 @@
/**
* useNativeMapLayers Mercator/Globe MapLibre hook
*
* :
* - projectionBusy / isStyleLoaded
* - GeoJSON source /
* - Layer (ensureLayer)
* - Visibility
* - Globe (reorderGlobeFeatureLayers)
* - kickRepaint
* - Unmount cleanupLayers
*
* ,
* useEffect에서 .
*/
import { useEffect, useRef, type MutableRefObject } from 'react';
import maplibregl, { type GeoJSONSourceSpecification, type LayerSpecification } from 'maplibre-gl';
import { ensureGeoJsonSource, ensureLayer, setLayerVisibility, cleanupLayers } from '../lib/layerHelpers';
import { kickRepaint, onMapStyleReady } from '../lib/mapCore';
/* ── Public types ──────────────────────────────────────────────────── */
export interface NativeSourceConfig {
id: string;
data: GeoJSON.GeoJSON | null;
/** GeoJSON source 옵션 (tolerance, buffer 등) */
options?: Partial<Omit<GeoJSONSourceSpecification, 'type' | 'data'>>;
}
export interface NativeLayerSpec {
id: string;
type: 'line' | 'fill' | 'circle' | 'symbol';
sourceId: string;
paint: Record<string, unknown>;
layout?: Record<string, unknown>;
filter?: unknown[];
minzoom?: number;
maxzoom?: number;
}
export interface NativeMapLayersConfig {
/** GeoJSON 데이터 소스 (다중 지원) */
sources: NativeSourceConfig[];
/** 레이어 스펙 배열 (생성 순서대로) */
layers: NativeLayerSpec[];
/** 전체 레이어 on/off */
visible: boolean;
/**
* ID.
* .
*/
beforeLayer?: string | string[];
/**
* () .
* .
*/
onAfterSetup?: (map: maplibregl.Map) => void;
}
/* ── Hook ──────────────────────────────────────────────────────────── */
/**
* @param mapRef - Map ref
* @param projectionBusyRef - ref
* @param reorderGlobeFeatureLayers - Globe
* @param config - //visibility
* @param deps - .
* (subcableGeo, overlays.subcables, projection, mapSyncEpoch )
*/
export function useNativeMapLayers(
mapRef: MutableRefObject<maplibregl.Map | null>,
projectionBusyRef: MutableRefObject<boolean>,
reorderGlobeFeatureLayers: () => void,
config: NativeMapLayersConfig,
deps: readonly unknown[],
) {
// 최신 config를 항상 읽기 위한 ref (deps에 config 객체를 넣지 않기 위함)
const configRef = useRef(config);
useEffect(() => {
configRef.current = config;
});
/* ── 레이어 생성/데이터 업데이트 ─────────────────────────────────── */
useEffect(() => {
const map = mapRef.current;
if (!map) return;
const ensure = () => {
const cfg = configRef.current;
if (projectionBusyRef.current) return;
// 1. Visibility 토글
for (const spec of cfg.layers) {
setLayerVisibility(map, spec.id, cfg.visible);
}
// 2. 데이터가 있는 source가 하나도 없으면 종료
const hasData = cfg.sources.some((s) => s.data != null);
if (!hasData) return;
if (!map.isStyleLoaded()) return;
try {
// 3. Source 생성/업데이트
for (const src of cfg.sources) {
if (src.data) {
ensureGeoJsonSource(map, src.id, src.data, src.options);
}
}
// 4. Before layer 해석
let before: string | undefined;
if (cfg.beforeLayer) {
const candidates = Array.isArray(cfg.beforeLayer) ? cfg.beforeLayer : [cfg.beforeLayer];
for (const candidate of candidates) {
if (map.getLayer(candidate)) {
before = candidate;
break;
}
}
}
// 5. Layer 생성
const vis = cfg.visible ? 'visible' : 'none';
for (const spec of cfg.layers) {
const layerDef: Record<string, unknown> = {
id: spec.id,
type: spec.type,
source: spec.sourceId,
paint: spec.paint,
layout: { ...spec.layout, visibility: vis },
};
if (spec.filter) layerDef.filter = spec.filter;
if (spec.minzoom != null) layerDef.minzoom = spec.minzoom;
if (spec.maxzoom != null) layerDef.maxzoom = spec.maxzoom;
ensureLayer(map, layerDef as unknown as LayerSpecification, { before });
}
// 6. Post-setup callback
if (cfg.onAfterSetup) {
cfg.onAfterSetup(map);
}
} catch (e) {
console.warn('Native map layers setup failed:', e);
} finally {
reorderGlobeFeatureLayers();
kickRepaint(map);
}
};
const stop = onMapStyleReady(map, ensure);
return () => {
stop();
};
// eslint-disable-next-line react-hooks/exhaustive-deps
}, deps);
/* ── Unmount cleanup ─────────────────────────────────────────────── */
useEffect(() => {
const mapInstance = mapRef.current;
return () => {
if (!mapInstance) return;
const cfg = configRef.current;
const layerIds = [...cfg.layers].reverse().map((l) => l.id);
const sourceIds = [...cfg.sources].reverse().map((s) => s.id);
cleanupLayers(mapInstance, layerIds, sourceIds);
};
}, []);
}

파일 보기

@ -3,7 +3,9 @@ import type maplibregl from 'maplibre-gl';
import { MapboxOverlay } from '@deck.gl/mapbox'; import { MapboxOverlay } from '@deck.gl/mapbox';
import { MaplibreDeckCustomLayer } from '../MaplibreDeckCustomLayer'; import { MaplibreDeckCustomLayer } from '../MaplibreDeckCustomLayer';
import type { MapProjectionId } from '../types'; import type { MapProjectionId } from '../types';
import { DECK_VIEW_ID } from '../constants';
import { kickRepaint, onMapStyleReady, extractProjectionType } from '../lib/mapCore'; import { kickRepaint, onMapStyleReady, extractProjectionType } from '../lib/mapCore';
import { removeLayerIfExists } from '../lib/layerHelpers';
export function useProjectionToggle( export function useProjectionToggle(
mapRef: MutableRefObject<maplibregl.Map | null>, mapRef: MutableRefObject<maplibregl.Map | null>,
@ -13,13 +15,14 @@ export function useProjectionToggle(
projectionBusyRef: MutableRefObject<boolean>, projectionBusyRef: MutableRefObject<boolean>,
opts: { opts: {
projection: MapProjectionId; projection: MapProjectionId;
clearGlobeNativeLayers: () => void;
ensureMercatorOverlay: () => MapboxOverlay | null; ensureMercatorOverlay: () => MapboxOverlay | null;
onProjectionLoadingChange?: (loading: boolean) => void; onProjectionLoadingChange?: (loading: boolean) => void;
pulseMapSync: () => void; pulseMapSync: () => void;
setMapSyncEpoch: (updater: (prev: number) => number) => void; setMapSyncEpoch: (updater: (prev: number) => number) => void;
}, },
): () => void { ): () => void {
const { projection, ensureMercatorOverlay, onProjectionLoadingChange, pulseMapSync, setMapSyncEpoch } = opts; const { projection, clearGlobeNativeLayers, ensureMercatorOverlay, onProjectionLoadingChange, pulseMapSync, setMapSyncEpoch } = opts;
const projectionBusyTokenRef = useRef(0); const projectionBusyTokenRef = useRef(0);
const projectionBusyTimerRef = useRef<ReturnType<typeof window.setTimeout> | null>(null); const projectionBusyTimerRef = useRef<ReturnType<typeof window.setTimeout> | null>(null);
@ -68,7 +71,7 @@ export function useProjectionToggle(
if (!projectionBusyRef.current || projectionBusyTokenRef.current !== token) return; if (!projectionBusyRef.current || projectionBusyTokenRef.current !== token) return;
console.debug('Projection loading fallback timeout reached.'); console.debug('Projection loading fallback timeout reached.');
endProjectionLoading(); endProjectionLoading();
}, 2000); }, 4000);
}, },
[clearProjectionBusyTimer, endProjectionLoading, onProjectionLoadingChange], [clearProjectionBusyTimer, endProjectionLoading, onProjectionLoadingChange],
); );
@ -88,17 +91,6 @@ export function useProjectionToggle(
if (!map.isStyleLoaded()) return; if (!map.isStyleLoaded()) return;
const ordering = [ const ordering = [
'subcables-hitarea',
'subcables-casing',
'subcables-line',
'subcables-glow',
'subcables-points',
'subcables-label',
'vessel-track-line',
'vessel-track-line-hitarea',
'vessel-track-arrow',
'vessel-track-pts',
'vessel-track-pts-highlight',
'zones-fill', 'zones-fill',
'zones-line', 'zones-line',
'zones-label', 'zones-label',
@ -116,6 +108,7 @@ export function useProjectionToggle(
'pair-lines-ml', 'pair-lines-ml',
'fc-lines-ml', 'fc-lines-ml',
'pair-range-ml', 'pair-range-ml',
'fleet-circles-ml-fill',
'fleet-circles-ml', 'fleet-circles-ml',
]; ];
@ -177,14 +170,45 @@ export function useProjectionToggle(
if (isTransition) setProjectionLoading(true); if (isTransition) setProjectionLoading(true);
// 파괴하지 않고 레이어만 비움 — 양쪽 파이프라인 항상 유지 const disposeMercatorOverlays = () => {
const quietMercatorOverlays = () => { const disposeOne = (target: MapboxOverlay | null, toNull: 'base' | 'interaction') => {
try { overlayRef.current?.setProps({ layers: [] } as never); } catch { /* ignore */ } if (!target) return;
try { overlayInteractionRef.current?.setProps({ layers: [] } as never); } catch { /* ignore */ } 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 quietGlobeDeckLayer = () => { const disposeGlobeDeckLayer = () => {
try { globeDeckLayerRef.current?.setProps({ layers: [] } as never); } catch { /* ignore */ } const current = globeDeckLayerRef.current;
if (!current) return;
removeLayerIfExists(map, current.id);
try {
current.requestFinalize();
} catch {
// ignore
}
globeDeckLayerRef.current = null;
}; };
const syncProjectionAndDeck = () => { const syncProjectionAndDeck = () => {
@ -206,9 +230,11 @@ export function useProjectionToggle(
const shouldSwitchProjection = currentProjection !== next; const shouldSwitchProjection = currentProjection !== next;
if (projection === 'globe') { if (projection === 'globe') {
quietMercatorOverlays(); disposeMercatorOverlays();
clearGlobeNativeLayers();
} else { } else {
quietGlobeDeckLayer(); disposeGlobeDeckLayer();
clearGlobeNativeLayers();
} }
try { try {
@ -216,17 +242,6 @@ export function useProjectionToggle(
map.setProjection({ type: next }); map.setProjection({ type: next });
} }
map.setRenderWorldCopies(next !== 'globe'); map.setRenderWorldCopies(next !== 'globe');
// Globe에서는 easeTo around 미지원 → scrollZoom 동작 전환
try {
map.scrollZoom.disable();
if (next === 'globe') {
map.scrollZoom.enable();
} else {
map.scrollZoom.enable({ around: 'center' });
}
} catch { /* ignore */ }
if (shouldSwitchProjection && currentProjection !== next && !cancelled && retries < maxRetries) { if (shouldSwitchProjection && currentProjection !== next && !cancelled && retries < maxRetries) {
retries += 1; retries += 1;
window.requestAnimationFrame(() => syncProjectionAndDeck()); window.requestAnimationFrame(() => syncProjectionAndDeck());
@ -242,9 +257,17 @@ export function useProjectionToggle(
console.warn('Projection switch failed:', e); console.warn('Projection switch failed:', e);
} }
// 양쪽 overlay가 항상 존재하므로 재생성 불필요
// deck-globe가 map에서 빠져있을 경우에만 재추가
if (projection === 'globe') { if (projection === 'globe') {
disposeGlobeDeckLayer();
if (!globeDeckLayerRef.current) {
globeDeckLayerRef.current = new MaplibreDeckCustomLayer({
id: 'deck-globe',
viewId: DECK_VIEW_ID,
deckProps: { layers: [] },
});
}
const layer = globeDeckLayerRef.current; const layer = globeDeckLayerRef.current;
const layerId = layer?.id; const layerId = layer?.id;
if (layer && layerId && map.isStyleLoaded() && !map.getLayer(layerId)) { if (layer && layerId && map.isStyleLoaded() && !map.getLayer(layerId)) {
@ -253,8 +276,14 @@ export function useProjectionToggle(
} catch { } catch {
// ignore // ignore
} }
if (!map.getLayer(layerId) && !cancelled && retries < maxRetries) {
retries += 1;
window.requestAnimationFrame(() => syncProjectionAndDeck());
return;
}
} }
} else { } else {
disposeGlobeDeckLayer();
ensureMercatorOverlay(); ensureMercatorOverlay();
} }
@ -289,7 +318,7 @@ export function useProjectionToggle(
if (settleCleanup) settleCleanup(); if (settleCleanup) settleCleanup();
if (isTransition) setProjectionLoading(false); if (isTransition) setProjectionLoading(false);
}; };
}, [projection, ensureMercatorOverlay, reorderGlobeFeatureLayers, setProjectionLoading]); }, [projection, clearGlobeNativeLayers, ensureMercatorOverlay, reorderGlobeFeatureLayers, setProjectionLoading]);
return reorderGlobeFeatureLayers; return reorderGlobeFeatureLayers;
} }

파일 보기

@ -1,300 +0,0 @@
import { useEffect, useMemo, useRef, type MutableRefObject } from 'react';
import maplibregl from 'maplibre-gl';
import type { SubcableGeoJson } from '../../../entities/subcable/model/types';
import type { MapToggleState } from '../../../features/mapToggles/MapToggles';
import type { MapProjectionId } from '../types';
import { kickRepaint } from '../lib/mapCore';
import { useNativeMapLayers, type NativeLayerSpec } from './useNativeMapLayers';
/* ── Layer / Source IDs ─────────────────────────────────────────────── */
const SRC_ID = 'subcables-src';
const POINTS_SRC_ID = 'subcables-pts-src';
const HITAREA_ID = 'subcables-hitarea';
const CASING_ID = 'subcables-casing';
const LINE_ID = 'subcables-line';
const GLOW_ID = 'subcables-glow';
const POINTS_ID = 'subcables-points';
const LABEL_ID = 'subcables-label';
const HOVER_LABEL_ID = 'subcables-hover-label';
/* ── Paint defaults (used for layer creation + hover reset) ──────── */
const LINE_OPACITY_DEFAULT = ['interpolate', ['linear'], ['zoom'], 2, 0.7, 6, 0.82, 10, 0.92];
const LINE_WIDTH_DEFAULT = ['interpolate', ['linear'], ['zoom'], 2, 1.6, 6, 2.5, 10, 4.0];
const CASING_OPACITY_DEFAULT = ['interpolate', ['linear'], ['zoom'], 2, 0.3, 5, 0.5, 8, 0.65];
const CASING_WIDTH_DEFAULT = ['interpolate', ['linear'], ['zoom'], 2, 2.4, 6, 3.6, 10, 5.5];
const POINTS_OPACITY_DEFAULT = ['interpolate', ['linear'], ['zoom'], 2, 0.5, 5, 0.7, 8, 0.85];
const POINTS_RADIUS_DEFAULT = ['interpolate', ['linear'], ['zoom'], 2, 1.5, 6, 2.5, 10, 4];
/* ── Layer specifications ────────────────────────────────────────── */
const LAYER_SPECS: NativeLayerSpec[] = [
{
id: HITAREA_ID,
type: 'line',
sourceId: SRC_ID,
paint: { 'line-color': 'rgba(0,0,0,0)', 'line-width': 14, 'line-opacity': 0 },
layout: { 'line-cap': 'round', 'line-join': 'round' },
minzoom: 3,
},
{
id: CASING_ID,
type: 'line',
sourceId: SRC_ID,
paint: {
'line-color': 'rgba(0,0,0,0.55)',
'line-width': CASING_WIDTH_DEFAULT,
'line-opacity': CASING_OPACITY_DEFAULT,
},
layout: { 'line-cap': 'round', 'line-join': 'round' },
minzoom: 3,
},
{
id: LINE_ID,
type: 'line',
sourceId: SRC_ID,
paint: {
'line-color': ['get', 'color'],
'line-opacity': LINE_OPACITY_DEFAULT,
'line-width': LINE_WIDTH_DEFAULT,
},
layout: { 'line-cap': 'round', 'line-join': 'round' },
},
{
id: GLOW_ID,
type: 'line',
sourceId: SRC_ID,
paint: {
'line-color': '#ffffff',
'line-opacity': 0,
'line-width': ['interpolate', ['linear'], ['zoom'], 2, 10, 6, 16, 10, 24],
'line-blur': ['interpolate', ['linear'], ['zoom'], 2, 4, 6, 6, 10, 8],
},
filter: ['==', ['get', 'id'], ''],
layout: { 'line-cap': 'round', 'line-join': 'round' },
minzoom: 3,
},
{
id: POINTS_ID,
type: 'circle',
sourceId: POINTS_SRC_ID,
paint: {
'circle-radius': POINTS_RADIUS_DEFAULT,
'circle-color': ['get', 'color'],
'circle-opacity': POINTS_OPACITY_DEFAULT,
'circle-stroke-color': 'rgba(0,0,0,0.5)',
'circle-stroke-width': 0.5,
},
minzoom: 3,
},
{
id: LABEL_ID,
type: 'symbol',
sourceId: SRC_ID,
paint: {
'text-color': 'rgba(220,232,245,0.82)',
'text-halo-color': 'rgba(2,6,23,0.9)',
'text-halo-width': 1.2,
'text-halo-blur': 0.5,
'text-opacity': ['interpolate', ['linear'], ['zoom'], 4, 0, 5, 0.7, 8, 0.88],
},
layout: {
'symbol-placement': 'line',
'text-field': ['get', 'name'],
'text-size': ['interpolate', ['linear'], ['zoom'], 4, 9, 8, 11, 12, 13],
'text-font': ['Noto Sans Regular', 'Open Sans Regular'],
'text-allow-overlap': false,
'text-padding': 8,
'text-rotation-alignment': 'map',
},
minzoom: 4,
},
{
id: HOVER_LABEL_ID,
type: 'symbol',
sourceId: SRC_ID,
paint: {
'text-color': '#ffffff',
'text-halo-color': 'rgba(0,0,0,0.85)',
'text-halo-width': 2,
'text-halo-blur': 0.5,
'text-opacity': 0,
},
layout: {
'symbol-placement': 'line',
'text-field': ['get', 'name'],
'text-size': ['interpolate', ['linear'], ['zoom'], 2, 14, 6, 17, 10, 20],
'text-font': ['Noto Sans Bold', 'Open Sans Bold'],
'text-allow-overlap': true,
'text-padding': 2,
'text-rotation-alignment': 'map',
},
filter: ['==', ['get', 'id'], ''],
minzoom: 2,
},
];
export function useSubcablesLayer(
mapRef: MutableRefObject<maplibregl.Map | null>,
projectionBusyRef: MutableRefObject<boolean>,
reorderGlobeFeatureLayers: () => void,
opts: {
subcableGeo: SubcableGeoJson | null;
overlays: MapToggleState;
projection: MapProjectionId;
mapSyncEpoch: number;
hoveredCableId: string | null;
onHoverCable: (cableId: string | null) => void;
onClickCable: (cableId: string | null) => void;
},
) {
const { subcableGeo, overlays, projection, mapSyncEpoch, hoveredCableId, onHoverCable, onClickCable } = opts;
const onHoverRef = useRef(onHoverCable);
const onClickRef = useRef(onClickCable);
const hoveredCableIdRef = useRef(hoveredCableId);
useEffect(() => {
onHoverRef.current = onHoverCable;
onClickRef.current = onClickCable;
hoveredCableIdRef.current = hoveredCableId;
});
/* ── Derived point features ──────────────────────────────────────── */
const pointsGeoJson = useMemo<GeoJSON.FeatureCollection>(() => {
if (!subcableGeo) return { type: 'FeatureCollection', features: [] };
const features: GeoJSON.Feature<GeoJSON.Point>[] = [];
for (const f of subcableGeo.features) {
const coords = f.properties.coordinates;
if (!coords || coords.length < 2) continue;
features.push({
type: 'Feature',
properties: { id: f.properties.id, color: f.properties.color },
geometry: { type: 'Point', coordinates: coords },
});
}
return { type: 'FeatureCollection', features };
}, [subcableGeo]);
/* ================================================================
* Effect 1: Layer creation & data update (via useNativeMapLayers)
* ================================================================ */
useNativeMapLayers(
mapRef,
projectionBusyRef,
reorderGlobeFeatureLayers,
{
sources: [
{ id: SRC_ID, data: subcableGeo, options: { tolerance: 1, buffer: 64 } },
{ id: POINTS_SRC_ID, data: pointsGeoJson },
],
layers: LAYER_SPECS,
visible: overlays.subcables,
beforeLayer: ['zones-fill', 'deck-globe'],
onAfterSetup: (map) => applyHoverHighlight(map, hoveredCableIdRef.current),
},
[subcableGeo, pointsGeoJson, overlays.subcables, projection, mapSyncEpoch, reorderGlobeFeatureLayers],
);
/* ================================================================
* Effect 2: Hover highlight (paint-only, no layer creation)
* ================================================================ */
useEffect(() => {
const map = mapRef.current;
if (!map || !map.isStyleLoaded()) return;
if (projectionBusyRef.current) return;
if (!map.getLayer(LINE_ID)) return;
applyHoverHighlight(map, hoveredCableId);
kickRepaint(map);
}, [hoveredCableId]);
/* ================================================================
* Effect 3: Mouse events (bind to hit-area for easy hovering)
* ================================================================ */
useEffect(() => {
const map = mapRef.current;
if (!map) return;
if (!overlays.subcables) return;
let cancelled = false;
let retryTimer: ReturnType<typeof setTimeout> | null = null;
const onMouseMove = (e: maplibregl.MapMouseEvent & { features?: maplibregl.MapGeoJSONFeature[] }) => {
const cableId = e.features?.[0]?.properties?.id;
if (typeof cableId === 'string' && cableId) {
map.getCanvas().style.cursor = 'pointer';
onHoverRef.current(cableId);
}
};
const onMouseLeave = () => {
map.getCanvas().style.cursor = '';
onHoverRef.current(null);
};
const onClick = (e: maplibregl.MapMouseEvent & { features?: maplibregl.MapGeoJSONFeature[] }) => {
const cableId = e.features?.[0]?.properties?.id;
if (typeof cableId === 'string' && cableId) {
onClickRef.current(cableId);
}
};
const bindEvents = () => {
if (cancelled) return;
const targetLayer = map.getLayer(HITAREA_ID) ? HITAREA_ID : map.getLayer(LINE_ID) ? LINE_ID : null;
if (!targetLayer) {
retryTimer = setTimeout(bindEvents, 200);
return;
}
map.on('mousemove', targetLayer, onMouseMove);
map.on('mouseleave', targetLayer, onMouseLeave);
map.on('click', targetLayer, onClick);
};
if (map.isStyleLoaded()) {
bindEvents();
} else {
map.once('idle', bindEvents);
}
return () => {
cancelled = true;
if (retryTimer) clearTimeout(retryTimer);
try {
map.off('mousemove', HITAREA_ID, onMouseMove);
map.off('mouseleave', HITAREA_ID, onMouseLeave);
map.off('click', HITAREA_ID, onClick);
map.off('mousemove', LINE_ID, onMouseMove);
map.off('mouseleave', LINE_ID, onMouseLeave);
map.off('click', LINE_ID, onClick);
} catch {
// ignore
}
};
}, [overlays.subcables, mapSyncEpoch]);
}
/* ── Hover highlight helper (paint-only mutations) ────────────────── */
// 기본 레이어는 항상 기본값 유지, glow 레이어(filter 기반)로만 호버 강조
function applyHoverHighlight(map: maplibregl.Map, hoveredId: string | null) {
const noMatch = ['==', ['get', 'id'], ''] as never;
if (hoveredId) {
const matchExpr = ['==', ['get', 'id'], hoveredId];
if (map.getLayer(GLOW_ID)) {
map.setFilter(GLOW_ID, matchExpr as never);
map.setPaintProperty(GLOW_ID, 'line-opacity', 0.55);
}
if (map.getLayer(HOVER_LABEL_ID)) {
map.setFilter(HOVER_LABEL_ID, matchExpr as never);
map.setPaintProperty(HOVER_LABEL_ID, 'text-opacity', 1.0);
}
} else {
if (map.getLayer(GLOW_ID)) {
map.setFilter(GLOW_ID, noMatch);
map.setPaintProperty(GLOW_ID, 'line-opacity', 0);
}
if (map.getLayer(HOVER_LABEL_ID)) {
map.setFilter(HOVER_LABEL_ID, noMatch);
map.setPaintProperty(HOVER_LABEL_ID, 'text-opacity', 0);
}
}
}

파일 보기

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

파일 보기

@ -6,58 +6,12 @@ import maplibregl, {
import type { BaseMapId, BathyZoomRange, MapProjectionId } from '../types'; import type { BaseMapId, BathyZoomRange, MapProjectionId } from '../types';
import { getLayerId, getMapTilerKey } from '../lib/mapCore'; import { getLayerId, getMapTilerKey } from '../lib/mapCore';
export const SHALLOW_WATER_FILL_DEFAULT = '#14606e';
export const SHALLOW_WATER_LINE_DEFAULT = '#114f5c';
const BATHY_ZOOM_RANGES: BathyZoomRange[] = [ const BATHY_ZOOM_RANGES: BathyZoomRange[] = [
{ id: 'bathymetry-fill', mercator: [3, 24], globe: [3, 24] }, { id: 'bathymetry-fill', mercator: [6, 24], globe: [8, 24] },
{ id: 'bathymetry-borders-major', mercator: [3, 7], globe: [3, 7] }, { id: 'bathymetry-borders', mercator: [6, 24], globe: [8, 24] },
{ id: 'bathymetry-borders', mercator: [7, 24], globe: [7, 24] }, { id: 'bathymetry-borders-major', mercator: [4, 24], globe: [8, 24] },
{ id: 'bathymetry-lines-coarse', mercator: [5, 7], globe: [5, 7] },
{ id: 'bathymetry-lines-major', mercator: [7, 9], globe: [7, 9] },
{ id: 'bathymetry-lines-detail', mercator: [9, 24], globe: [9, 24] },
{ id: 'bathymetry-labels-coarse', mercator: [6, 9], globe: [6, 9] },
{ id: 'bathymetry-labels', mercator: [9, 24], globe: [9, 24] },
]; ];
/**
* LOD vertex가 minzoom을
* , .
* .
*/
function applyLandLayerLOD(style: StyleSpecification): void {
if (!style.layers || !Array.isArray(style.layers)) return;
// source-layer → 렌더링을 시작할 최소 줌 레벨
// globe 모드 줌아웃 시 vertex 65535 초과로 GPU 렌더링 아티팩트(노란 막대) 방지
const LOD_MINZOOM: Record<string, number> = {
'landcover': 9,
'globallandcover': 9,
'landuse': 11,
'boundary': 5,
'transportation': 8,
'transportation_name': 10,
'building': 14,
'housenumber': 16,
'aeroway': 11,
'park': 10,
'mountain_peak': 11,
};
for (const layer of style.layers as unknown as LayerSpecification[]) {
const spec = layer as Record<string, unknown>;
const sourceLayer = spec['source-layer'] as string | undefined;
if (!sourceLayer) continue;
const lodMin = LOD_MINZOOM[sourceLayer];
if (lodMin === undefined) continue;
// 기존 minzoom보다 높을 때만 덮어씀
const current = (spec.minzoom as number) ?? 0;
if (lodMin > current) {
spec.minzoom = lodMin;
}
}
}
export function injectOceanBathymetryLayers(style: StyleSpecification, maptilerKey: string) { export function injectOceanBathymetryLayers(style: StyleSpecification, maptilerKey: string) {
const oceanSourceId = 'maptiler-ocean'; const oceanSourceId = 'maptiler-ocean';
@ -74,13 +28,17 @@ export function injectOceanBathymetryLayers(style: StyleSpecification, maptilerK
const depth = ['to-number', ['get', 'depth']] as unknown as number[]; const depth = ['to-number', ['get', 'depth']] as unknown as number[];
const depthLabel = ['concat', ['to-string', ['*', depth, -1]], 'm'] as unknown as string[]; const depthLabel = ['concat', ['to-string', ['*', depth, -1]], 'm'] as unknown as string[];
// 수심 색상: 전체 범위 (-8000m ~ 0m) // Bug #3 fix: shallow depths now use brighter teal tones to distinguish from deep ocean
const bathyFillColor = [ const bathyFillColor = [
'interpolate', 'interpolate',
['linear'], ['linear'],
depth, depth,
-11000,
'#00040b',
-8000, -8000,
'#010610', '#010610',
-6000,
'#020816',
-4000, -4000,
'#030c1c', '#030c1c',
-2000, -2000,
@ -103,21 +61,12 @@ export function injectOceanBathymetryLayers(style: StyleSpecification, maptilerK
'#2097a6', '#2097a6',
] as const; ] as const;
// --- Depth tiers for zoom-based LOD ---
// 줌 기반 LOD로 vertex 제어 — 줌아웃에선 주요 등심선만, 줌인에서 점진적 디테일
const DEPTHS_COARSE = [-1000, -2000];
const DEPTHS_MEDIUM = [-100, -500, -1000, -2000, -4000];
const DEPTHS_DETAIL = [-50, -100, -200, -500, -1000, -2000, -4000, -8000];
const depthIn = (depths: number[]) =>
['in', depth, ['literal', depths]] as unknown[];
// === Fill (contour polygons) — 단일 레이어, 전체 depth ===
const bathyFill: LayerSpecification = { const bathyFill: LayerSpecification = {
id: 'bathymetry-fill', id: 'bathymetry-fill',
type: 'fill', type: 'fill',
source: oceanSourceId, source: oceanSourceId,
'source-layer': 'contour', 'source-layer': 'contour',
minzoom: 3, minzoom: 5,
maxzoom: 24, maxzoom: 24,
paint: { paint: {
'fill-color': bathyFillColor, 'fill-color': bathyFillColor,
@ -125,141 +74,107 @@ export function injectOceanBathymetryLayers(style: StyleSpecification, maptilerK
}, },
} as unknown as LayerSpecification; } as unknown as LayerSpecification;
// === Borders (contour polygon edges) — 2-tier LOD === const bathyBandBorders: LayerSpecification = {
// z3-z7: 1000m, 2000m 경계만
const bathyBordersMajor: LayerSpecification = {
id: 'bathymetry-borders-major',
type: 'line',
source: oceanSourceId,
'source-layer': 'contour',
minzoom: 3,
maxzoom: 7,
filter: depthIn(DEPTHS_COARSE) as unknown as unknown[],
paint: {
'line-color': 'rgba(255,255,255,0.14)',
'line-opacity': ['interpolate', ['linear'], ['zoom'], 3, 0.10, 6, 0.16],
'line-blur': ['interpolate', ['linear'], ['zoom'], 3, 0.5, 6, 0.35],
'line-width': ['interpolate', ['linear'], ['zoom'], 3, 0.25, 6, 0.4],
},
} as unknown as LayerSpecification;
// z7+: 전체 등심선 경계
const bathyBorders: LayerSpecification = {
id: 'bathymetry-borders', id: 'bathymetry-borders',
type: 'line', type: 'line',
source: oceanSourceId, source: oceanSourceId,
'source-layer': 'contour', 'source-layer': 'contour',
minzoom: 7, minzoom: 5,
maxzoom: 24, maxzoom: 24,
paint: { paint: {
'line-color': 'rgba(255,255,255,0.06)', 'line-color': 'rgba(255,255,255,0.06)',
'line-opacity': ['interpolate', ['linear'], ['zoom'], 7, 0.18, 12, 0.22], 'line-opacity': ['interpolate', ['linear'], ['zoom'], 4, 0.12, 8, 0.18, 12, 0.22],
'line-blur': ['interpolate', ['linear'], ['zoom'], 7, 0.3, 10, 0.2], 'line-blur': ['interpolate', ['linear'], ['zoom'], 4, 0.8, 10, 0.2],
'line-width': ['interpolate', ['linear'], ['zoom'], 7, 0.35, 12, 0.6], 'line-width': ['interpolate', ['linear'], ['zoom'], 4, 0.2, 8, 0.35, 12, 0.6],
}, },
} as unknown as LayerSpecification; } as unknown as LayerSpecification;
// === Contour lines (contour_line) — 3-tier LOD === const bathyLinesMinor: LayerSpecification = {
// z5-z7: 1000m, 2000m만 id: 'bathymetry-lines',
const bathyLinesCoarse: LayerSpecification = {
id: 'bathymetry-lines-coarse',
type: 'line', type: 'line',
source: oceanSourceId, source: oceanSourceId,
'source-layer': 'contour_line', 'source-layer': 'contour_line',
minzoom: 5, minzoom: 7,
maxzoom: 7,
filter: depthIn(DEPTHS_COARSE) as unknown as unknown[],
paint: { paint: {
'line-color': 'rgba(255,255,255,0.12)', 'line-color': [
'line-opacity': ['interpolate', ['linear'], ['zoom'], 5, 0.15, 7, 0.22], 'interpolate',
'line-blur': 0.5, ['linear'],
'line-width': ['interpolate', ['linear'], ['zoom'], 5, 0.4, 7, 0.6], 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],
}, },
} as unknown as LayerSpecification; } as unknown as LayerSpecification;
// z7-z9: 100, 500, 1000, 2000, 4000m const majorDepths = [-50, -100, -200, -500, -1000, -2000, -4000, -6000, -8000, -9500];
const bathyMajorDepthFilter: unknown[] = [
'in',
['to-number', ['get', 'depth']],
['literal', majorDepths],
] as unknown[];
const bathyLinesMajor: LayerSpecification = { const bathyLinesMajor: LayerSpecification = {
id: 'bathymetry-lines-major', id: 'bathymetry-lines-major',
type: 'line', type: 'line',
source: oceanSourceId, source: oceanSourceId,
'source-layer': 'contour_line', 'source-layer': 'contour_line',
minzoom: 7, minzoom: 7,
maxzoom: 9, maxzoom: 24,
filter: depthIn(DEPTHS_MEDIUM) as unknown as unknown[], filter: bathyMajorDepthFilter as unknown as unknown[],
paint: { paint: {
'line-color': 'rgba(255,255,255,0.16)', 'line-color': 'rgba(255,255,255,0.16)',
'line-opacity': ['interpolate', ['linear'], ['zoom'], 7, 0.22, 9, 0.28], 'line-opacity': ['interpolate', ['linear'], ['zoom'], 8, 0.22, 10, 0.28, 12, 0.34],
'line-blur': ['interpolate', ['linear'], ['zoom'], 7, 0.4, 9, 0.2], 'line-blur': ['interpolate', ['linear'], ['zoom'], 8, 0.4, 11, 0.2],
'line-width': ['interpolate', ['linear'], ['zoom'], 7, 0.6, 9, 0.95], 'line-width': ['interpolate', ['linear'], ['zoom'], 8, 0.6, 10, 0.95, 12, 1.3],
}, },
} as unknown as LayerSpecification; } as unknown as LayerSpecification;
// z9+: 50~8000m (풀 디테일) const bathyBandBordersMajor: LayerSpecification = {
const bathyLinesDetail: LayerSpecification = { id: 'bathymetry-borders-major',
id: 'bathymetry-lines-detail',
type: 'line', type: 'line',
source: oceanSourceId, source: oceanSourceId,
'source-layer': 'contour_line', 'source-layer': 'contour',
minzoom: 9, minzoom: 3,
maxzoom: 24, maxzoom: 24,
filter: depthIn(DEPTHS_DETAIL) as unknown as unknown[], filter: bathyMajorDepthFilter as unknown as unknown[],
paint: { paint: {
'line-color': 'rgba(255,255,255,0.16)', 'line-color': 'rgba(255,255,255,0.14)',
'line-opacity': ['interpolate', ['linear'], ['zoom'], 9, 0.28, 12, 0.34], 'line-opacity': ['interpolate', ['linear'], ['zoom'], 3, 0.10, 6, 0.16, 8, 0.2, 12, 0.26],
'line-blur': ['interpolate', ['linear'], ['zoom'], 9, 0.2, 11, 0.15], 'line-blur': ['interpolate', ['linear'], ['zoom'], 3, 0.5, 6, 0.35, 10, 0.15],
'line-width': ['interpolate', ['linear'], ['zoom'], 9, 0.95, 12, 1.3], 'line-width': ['interpolate', ['linear'], ['zoom'], 3, 0.25, 6, 0.4, 8, 0.55, 12, 0.85],
}, },
} as unknown as LayerSpecification; } as unknown as LayerSpecification;
// === Labels — 2-tier LOD ===
// z6-z9: 1000m, 2000m 라벨만
const bathyLabelsCoarse: LayerSpecification = {
id: 'bathymetry-labels-coarse',
type: 'symbol',
source: oceanSourceId,
'source-layer': 'contour_line',
minzoom: 6,
maxzoom: 9,
filter: depthIn(DEPTHS_COARSE) as unknown as unknown[],
layout: {
'symbol-placement': 'line',
'text-field': depthLabel,
'text-font': ['Noto Sans Regular', 'Open Sans Regular'],
'text-size': ['interpolate', ['linear'], ['zoom'], 6, 10, 9, 12],
'text-allow-overlap': false,
'text-padding': 4,
'text-rotation-alignment': 'map',
},
paint: {
'text-color': 'rgba(226,232,240,0.78)',
'text-halo-color': 'rgba(2,6,23,0.88)',
'text-halo-width': 1.2,
'text-halo-blur': 0.5,
},
} as unknown as LayerSpecification;
// z9+: 100~4000m 라벨
const bathyLabels: LayerSpecification = { const bathyLabels: LayerSpecification = {
id: 'bathymetry-labels', id: 'bathymetry-labels',
type: 'symbol', type: 'symbol',
source: oceanSourceId, source: oceanSourceId,
'source-layer': 'contour_line', 'source-layer': 'contour_line',
minzoom: 9, minzoom: 10,
filter: depthIn(DEPTHS_MEDIUM) as unknown as unknown[], filter: bathyMajorDepthFilter as unknown as unknown[],
layout: { layout: {
'symbol-placement': 'line', 'symbol-placement': 'line',
'text-field': depthLabel, 'text-field': depthLabel,
'text-font': ['Noto Sans Regular', 'Open Sans Regular'], 'text-font': ['Noto Sans Regular', 'Open Sans Regular'],
'text-size': ['interpolate', ['linear'], ['zoom'], 9, 12, 11, 14, 13, 16], 'text-size': ['interpolate', ['linear'], ['zoom'], 10, 12, 12, 14, 14, 15],
'text-allow-overlap': false, 'text-allow-overlap': false,
'text-padding': 4, 'text-padding': 2,
'text-rotation-alignment': 'map', 'text-rotation-alignment': 'map',
}, },
paint: { paint: {
'text-color': 'rgba(226,232,240,0.78)', 'text-color': 'rgba(226,232,240,0.72)',
'text-halo-color': 'rgba(2,6,23,0.88)', 'text-halo-color': 'rgba(2,6,23,0.82)',
'text-halo-width': 1.2, 'text-halo-width': 1.0,
'text-halo-blur': 0.5, 'text-halo-blur': 0.6,
}, },
} as unknown as LayerSpecification; } as unknown as LayerSpecification;
@ -268,21 +183,21 @@ export function injectOceanBathymetryLayers(style: StyleSpecification, maptilerK
type: 'symbol', type: 'symbol',
source: oceanSourceId, source: oceanSourceId,
'source-layer': 'landform', 'source-layer': 'landform',
minzoom: 6, minzoom: 8,
filter: ['has', 'name'] as unknown as unknown[], filter: ['has', 'name'] as unknown as unknown[],
layout: { layout: {
'text-field': ['get', 'name'] as unknown as unknown[], 'text-field': ['get', 'name'] as unknown as unknown[],
'text-font': ['Noto Sans Italic', 'Noto Sans Regular', 'Open Sans Italic', 'Open Sans Regular'], 'text-font': ['Noto Sans Italic', 'Noto Sans Regular', 'Open Sans Italic', 'Open Sans Regular'],
'text-size': ['interpolate', ['linear'], ['zoom'], 6, 10, 8, 12, 10, 13, 12, 14], 'text-size': ['interpolate', ['linear'], ['zoom'], 8, 11, 10, 12, 12, 13],
'text-allow-overlap': false, 'text-allow-overlap': false,
'text-anchor': 'center', 'text-anchor': 'center',
'text-offset': [0, 0.0], 'text-offset': [0, 0.0],
}, },
paint: { paint: {
'text-color': 'rgba(148,163,184,0.75)', 'text-color': 'rgba(148,163,184,0.70)',
'text-halo-color': 'rgba(2,6,23,0.88)', 'text-halo-color': 'rgba(2,6,23,0.85)',
'text-halo-width': 1.2, 'text-halo-width': 1.0,
'text-halo-blur': 0.6, 'text-halo-blur': 0.7,
}, },
} as unknown as LayerSpecification; } as unknown as LayerSpecification;
@ -294,8 +209,8 @@ export function injectOceanBathymetryLayers(style: StyleSpecification, maptilerK
// Brighten base-map water fills so shallow coasts, rivers & lakes blend naturally // Brighten base-map water fills so shallow coasts, rivers & lakes blend naturally
// with the bathymetry gradient instead of appearing as near-black voids. // with the bathymetry gradient instead of appearing as near-black voids.
const waterRegex = /(water|sea|ocean|river|lake|coast|bay)/i; const waterRegex = /(water|sea|ocean|river|lake|coast|bay)/i;
const SHALLOW_WATER_FILL = SHALLOW_WATER_FILL_DEFAULT; const SHALLOW_WATER_FILL = '#14606e';
const SHALLOW_WATER_LINE = SHALLOW_WATER_LINE_DEFAULT; const SHALLOW_WATER_LINE = '#114f5c';
for (const layer of layers) { for (const layer of layers) {
const id = getLayerId(layer); const id = getLayerId(layer);
if (!id) continue; if (!id) continue;
@ -326,12 +241,10 @@ export function injectOceanBathymetryLayers(style: StyleSpecification, maptilerK
const toInsert = [ const toInsert = [
bathyFill, bathyFill,
bathyBordersMajor, bathyBandBorders,
bathyBorders, bathyBandBordersMajor,
bathyLinesCoarse, bathyLinesMinor,
bathyLinesMajor, bathyLinesMajor,
bathyLinesDetail,
bathyLabelsCoarse,
bathyLabels, bathyLabels,
landformLabels, landformLabels,
].filter((l) => !existingIds.has(l.id)); ].filter((l) => !existingIds.has(l.id));
@ -357,20 +270,6 @@ export function applyBathymetryZoomProfile(map: maplibregl.Map, baseMap: BaseMap
// ignore // ignore
} }
} }
}
function applyKoreanLabels(style: StyleSpecification) {
if (!style.layers) return;
const koTextField = ['coalesce', ['get', 'name:ko'], ['get', 'name']];
for (const layer of style.layers as unknown as LayerSpecification[]) {
if ((layer as { type?: string }).type !== 'symbol') continue;
const layout = (layer as Record<string, unknown>).layout as
| Record<string, unknown>
| undefined;
if (!layout?.['text-field']) continue;
layout['text-field'] = koTextField;
}
} }
export async function resolveInitialMapStyle(signal: AbortSignal): Promise<string | StyleSpecification> { export async function resolveInitialMapStyle(signal: AbortSignal): Promise<string | StyleSpecification> {
@ -383,15 +282,11 @@ export async function resolveInitialMapStyle(signal: AbortSignal): Promise<strin
const res = await fetch(styleUrl, { signal, headers: { accept: 'application/json' } }); const res = await fetch(styleUrl, { signal, headers: { accept: 'application/json' } });
if (!res.ok) throw new Error(`MapTiler style fetch failed: ${res.status} ${res.statusText}`); if (!res.ok) throw new Error(`MapTiler style fetch failed: ${res.status} ${res.statusText}`);
const json = (await res.json()) as StyleSpecification; const json = (await res.json()) as StyleSpecification;
applyLandLayerLOD(json);
applyKoreanLabels(json);
injectOceanBathymetryLayers(json, key); injectOceanBathymetryLayers(json, key);
return json; return json;
} }
export async function resolveMapStyle(baseMap: BaseMapId, signal: AbortSignal): Promise<string | StyleSpecification> { export async function resolveMapStyle(baseMap: BaseMapId, signal: AbortSignal): Promise<string | StyleSpecification> {
// 레거시 베이스맵 비활성 — 향후 위성/라이트 테마 등 추가 시 재활용 if (baseMap === 'legacy') return '/map/styles/carto-dark.json';
// if (baseMap === 'legacy') return '/map/styles/carto-dark.json';
void baseMap;
return resolveInitialMapStyle(signal); return resolveInitialMapStyle(signal);
} }

파일 보기

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

파일 보기

@ -24,35 +24,30 @@ export function removeSourceIfExists(map: maplibregl.Map, sourceId: string) {
} }
} }
// Ship 레이어/소스는 useGlobeShips에서 visibility 토글로 관리 (재생성 비용 회피)
const GLOBE_NATIVE_LAYER_IDS = [ const GLOBE_NATIVE_LAYER_IDS = [
'ships-globe-halo',
'ships-globe-outline',
'ships-globe',
'ships-globe-label',
'ships-globe-hover-halo',
'ships-globe-hover-outline',
'ships-globe-hover',
'pair-lines-ml', 'pair-lines-ml',
'fc-lines-ml', 'fc-lines-ml',
'fleet-circles-ml-fill',
'fleet-circles-ml', 'fleet-circles-ml',
'pair-range-ml', 'pair-range-ml',
'subcables-hitarea',
'subcables-casing',
'subcables-line',
'subcables-glow',
'subcables-points',
'subcables-label',
'vessel-track-line',
'vessel-track-line-hitarea',
'vessel-track-arrow',
'vessel-track-pts',
'vessel-track-pts-highlight',
'deck-globe', 'deck-globe',
]; ];
const GLOBE_NATIVE_SOURCE_IDS = [ const GLOBE_NATIVE_SOURCE_IDS = [
'ships-globe-src',
'ships-globe-hover-src',
'pair-lines-ml-src', 'pair-lines-ml-src',
'fc-lines-ml-src', 'fc-lines-ml-src',
'fleet-circles-ml-src', 'fleet-circles-ml-src',
'fleet-circles-ml-fill-src',
'pair-range-ml-src', 'pair-range-ml-src',
'subcables-src',
'subcables-pts-src',
'vessel-track-line-src',
'vessel-track-pts-src',
]; ];
export function clearGlobeNativeLayers(map: maplibregl.Map) { export function clearGlobeNativeLayers(map: maplibregl.Map) {
@ -68,7 +63,6 @@ export function ensureGeoJsonSource(
map: maplibregl.Map, map: maplibregl.Map,
sourceId: string, sourceId: string,
data: GeoJSON.GeoJSON, data: GeoJSON.GeoJSON,
options?: Partial<Omit<GeoJSONSourceSpecification, 'type' | 'data'>>,
) { ) {
const existing = map.getSource(sourceId); const existing = map.getSource(sourceId);
if (existing) { if (existing) {
@ -77,7 +71,6 @@ export function ensureGeoJsonSource(
map.addSource(sourceId, { map.addSource(sourceId, {
type: 'geojson', type: 'geojson',
data, data,
...options,
} satisfies GeoJSONSourceSpecification); } satisfies GeoJSONSourceSpecification);
} }
} }
@ -101,22 +94,6 @@ export function setLayerVisibility(map: maplibregl.Map, layerId: string, visible
} }
} }
/**
* setLayoutProperty('visibility') wrapper .
* MapLibre는 setLayoutProperty style._changed = true를
* symbol layer의 placement를 . text-allow-overlap:false
* , .
*/
export function guardedSetVisibility(map: maplibregl.Map, layerId: string, target: 'visible' | 'none') {
if (!map.getLayer(layerId)) return;
try {
if (map.getLayoutProperty(layerId, 'visibility') === target) return;
map.setLayoutProperty(layerId, 'visibility', target);
} catch {
// ignore
}
}
export function cleanupLayers( export function cleanupLayers(
map: maplibregl.Map, map: maplibregl.Map,
layerIds: string[], layerIds: string[],

파일 보기

@ -1,30 +0,0 @@
/**
* 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();

파일 보기

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

파일 보기

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

파일 보기

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

파일 보기

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

891
package-lock.json generated

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

파일 보기

@ -10,8 +10,7 @@
"build:web": "npm -w @wing/web run build", "build:web": "npm -w @wing/web run build",
"build:api": "npm -w @wing/api run build", "build:api": "npm -w @wing/api run build",
"lint": "npm -w @wing/web run lint", "lint": "npm -w @wing/web run lint",
"prepare:data": "node scripts/prepare-zones.mjs && node scripts/prepare-legacy.mjs", "prepare:data": "node scripts/prepare-zones.mjs && node scripts/prepare-legacy.mjs"
"prepare:subcables": "node scripts/prepare-subcables.mjs"
}, },
"devDependencies": { "devDependencies": {
"xlsx": "^0.18.5" "xlsx": "^0.18.5"

파일 보기

@ -1,176 +0,0 @@
import fs from "node:fs/promises";
import path from "node:path";
import { fileURLToPath } from "node:url";
const __filename = fileURLToPath(import.meta.url);
const __dirname = path.dirname(__filename);
const OUT_DIR = path.resolve(__dirname, "..", "apps", "web", "public", "data", "subcables");
const GEO_URL = "https://www.submarinecablemap.com/api/v3/cable/cable-geo.json";
const DETAILS_URL_BASE = "https://www.submarinecablemap.com/api/v3/cable/";
const CONCURRENCY = Math.max(1, Math.min(24, Number(process.env.CONCURRENCY || 12)));
const TIMEOUT_MS = Math.max(5_000, Math.min(60_000, Number(process.env.TIMEOUT_MS || 20_000)));
function sleep(ms) {
return new Promise((resolve) => setTimeout(resolve, ms));
}
async function fetchText(url, { timeoutMs = TIMEOUT_MS } = {}) {
const controller = new AbortController();
const timeout = setTimeout(() => controller.abort(), timeoutMs);
try {
const res = await fetch(url, {
signal: controller.signal,
headers: {
accept: "application/json",
},
});
const text = await res.text();
const contentType = res.headers.get("content-type") || "";
if (!res.ok) {
throw new Error(`HTTP ${res.status} (${res.statusText})`);
}
return { text, contentType };
} finally {
clearTimeout(timeout);
}
}
async function fetchJson(url) {
const { text, contentType } = await fetchText(url);
if (!contentType.toLowerCase().includes("application/json")) {
const snippet = text.slice(0, 200).replace(/\s+/g, " ").trim();
throw new Error(`Unexpected content-type (${contentType || "unknown"}): ${snippet || "<empty>"}`);
}
try {
return JSON.parse(text);
} catch (e) {
throw new Error(`Invalid JSON: ${e instanceof Error ? e.message : String(e)}`);
}
}
async function fetchJsonWithRetry(url, attempts = 2) {
let lastErr = null;
for (let i = 0; i < attempts; i += 1) {
try {
return await fetchJson(url);
} catch (e) {
lastErr = e;
if (i < attempts - 1) {
await sleep(250 * (i + 1));
}
}
}
throw lastErr;
}
function pickCableDetails(raw) {
const obj = raw && typeof raw === "object" ? raw : {};
const landingPoints = Array.isArray(obj.landing_points) ? obj.landing_points : [];
return {
id: String(obj.id || ""),
name: String(obj.name || ""),
length: obj.length == null ? null : String(obj.length),
rfs: obj.rfs == null ? null : String(obj.rfs),
rfs_year: typeof obj.rfs_year === "number" ? obj.rfs_year : null,
is_planned: Boolean(obj.is_planned),
owners: obj.owners == null ? null : String(obj.owners),
suppliers: obj.suppliers == null ? null : String(obj.suppliers),
landing_points: landingPoints.map((lp) => {
const p = lp && typeof lp === "object" ? lp : {};
return {
id: String(p.id || ""),
name: String(p.name || ""),
country: String(p.country || ""),
is_tbd: p.is_tbd === true,
};
}),
notes: obj.notes == null ? null : String(obj.notes),
url: obj.url == null ? null : String(obj.url),
};
}
async function main() {
await fs.mkdir(OUT_DIR, { recursive: true });
console.log(`[subcables] fetching geojson: ${GEO_URL}`);
const geo = await fetchJsonWithRetry(GEO_URL, 3);
const geoPath = path.join(OUT_DIR, "cable-geo.json");
await fs.writeFile(geoPath, JSON.stringify(geo));
const features = Array.isArray(geo?.features) ? geo.features : [];
const ids = Array.from(
new Set(
features
.map((f) => f?.properties?.id)
.filter((v) => typeof v === "string" && v.trim().length > 0)
.map((v) => v.trim()),
),
).sort();
console.log(`[subcables] cables: ${ids.length} (concurrency=${CONCURRENCY}, timeoutMs=${TIMEOUT_MS})`);
const byId = {};
const failures = [];
let cursor = 0;
let completed = 0;
const startedAt = Date.now();
const worker = async () => {
for (;;) {
const idx = cursor;
cursor += 1;
if (idx >= ids.length) return;
const id = ids[idx];
const url = new URL(`${id}.json`, DETAILS_URL_BASE).toString();
try {
const raw = await fetchJsonWithRetry(url, 2);
const picked = pickCableDetails(raw);
if (!picked.id) {
throw new Error("Missing id in details response");
}
byId[id] = picked;
} catch (e) {
failures.push({ id, error: e instanceof Error ? e.message : String(e) });
} finally {
completed += 1;
if (completed % 25 === 0 || completed === ids.length) {
const sec = Math.max(1, Math.round((Date.now() - startedAt) / 1000));
const rate = (completed / sec).toFixed(1);
console.log(`[subcables] ${completed}/${ids.length} (${rate}/s)`);
}
}
}
};
await Promise.all(Array.from({ length: CONCURRENCY }, () => worker()));
const detailsOut = {
version: 1,
generated_at: new Date().toISOString(),
by_id: byId,
};
const detailsPath = path.join(OUT_DIR, "cable-details.min.json");
await fs.writeFile(detailsPath, JSON.stringify(detailsOut));
if (failures.length > 0) {
console.error(`[subcables] failures: ${failures.length}`);
for (const f of failures.slice(0, 30)) {
console.error(`- ${f.id}: ${f.error}`);
}
if (failures.length > 30) {
console.error(`- ... +${failures.length - 30} more`);
}
process.exitCode = 1;
}
console.log(`[subcables] wrote: ${geoPath}`);
console.log(`[subcables] wrote: ${detailsPath}`);
}
main().catch((e) => {
console.error(`[subcables] fatal: ${e instanceof Error ? e.stack || e.message : String(e)}`);
process.exitCode = 1;
});