Compare commits
No commits in common. "develop" and "codex/subcables-static" have entirely different histories.
develop
...
codex/subc
@ -26,11 +26,6 @@ jobs:
|
||||
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
|
||||
|
||||
54
CLAUDE.md
54
CLAUDE.md
@ -2,22 +2,34 @@
|
||||
|
||||
## 프로젝트 개요
|
||||
|
||||
- **타입**: React 19 + TypeScript 5.9 + Vite 7 (모노레포)
|
||||
- **타입**: React + TypeScript + Vite (모노레포)
|
||||
- **Node.js**: `.node-version` 참조 (v24)
|
||||
- **패키지 매니저**: npm (workspaces)
|
||||
- **구조**: apps/web (프론트엔드) + apps/api (백엔드 API 프록시)
|
||||
- **구조**: apps/web (프론트엔드) + apps/api (백엔드 API)
|
||||
|
||||
## 빌드 및 실행
|
||||
|
||||
```bash
|
||||
npm install # 의존성 설치
|
||||
npm run dev # 전체 개발 서버
|
||||
npm run dev:web # 프론트엔드 (Vite)
|
||||
npm run dev:api # 백엔드 (Fastify + tsx watch)
|
||||
npm run build # 전체 빌드 (web + api)
|
||||
npm run build:web # 프론트엔드만
|
||||
npm run lint # apps/web ESLint
|
||||
npm run prepare:data # 정적 데이터 준비
|
||||
# 의존성 설치
|
||||
npm install
|
||||
|
||||
# 전체 개발 서버
|
||||
npm run dev
|
||||
|
||||
# 개별 개발 서버
|
||||
npm run dev:web # 프론트엔드 (Vite)
|
||||
npm run dev:api # 백엔드 (Fastify + tsx watch)
|
||||
|
||||
# 빌드
|
||||
npm run build # 전체 빌드 (web + api)
|
||||
npm run build:web # 프론트엔드만
|
||||
npm run build:api # 백엔드만
|
||||
|
||||
# 린트
|
||||
npm run lint # apps/web ESLint
|
||||
|
||||
# 데이터 준비
|
||||
npm run prepare:data
|
||||
```
|
||||
|
||||
## 프로젝트 구조
|
||||
@ -25,18 +37,19 @@ npm run prepare:data # 정적 데이터 준비
|
||||
```
|
||||
gc-wing-dev/
|
||||
├── apps/
|
||||
│ ├── web/ # @wing/web - React 19 + Vite 7
|
||||
│ ├── web/ # React 19 + Vite 7 + MapLibre + Deck.gl
|
||||
│ │ └── src/
|
||||
│ │ ├── app/ # App.tsx, styles.css
|
||||
│ │ ├── entities/ # 도메인 모델 (aisTarget, vessel, zone, legacyVessel, subcable)
|
||||
│ │ ├── features/ # 기능 모듈 (aisPolling, legacyDashboard, map3dSettings, mapSettings, mapToggles, typeFilter)
|
||||
│ │ ├── pages/ # dashboard, login, denied, pending
|
||||
│ │ ├── shared/ # auth (Google OAuth), lib (geo, color, map), hooks (usePersistedState)
|
||||
│ │ └── widgets/ # map3d, vesselList, info, alarms, relations, aisInfo, aisTargetList, topbar, speed, legend, subcableInfo
|
||||
│ └── api/ # @wing/api - Fastify 5
|
||||
│ └── src/index.ts # AIS 프록시 + zones 엔드포인트
|
||||
│ │ ├── app/ # App.tsx, styles
|
||||
│ │ ├── entities/ # 도메인 모델 (vessel, zone, aisTarget, legacyVessel)
|
||||
│ │ ├── features/ # 기능 단위 (mapToggles, typeFilter, aisPolling 등)
|
||||
│ │ ├── pages/ # 페이지 (DashboardPage)
|
||||
│ │ ├── shared/ # 공통 유틸 (lib/geo, lib/color, lib/map)
|
||||
│ │ └── widgets/ # UI 위젯 (map3d, vesselList, info, alarms 등)
|
||||
│ └── api/ # Fastify 5 + TypeScript
|
||||
│ └── src/
|
||||
│ └── index.ts
|
||||
├── data/ # 정적 데이터
|
||||
├── scripts/ # prepare-zones.mjs, prepare-legacy.mjs
|
||||
├── scripts/ # 빌드 스크립트 (prepare-zones, prepare-legacy)
|
||||
└── legacy/ # 레거시 데이터
|
||||
```
|
||||
|
||||
@ -46,7 +59,6 @@ gc-wing-dev/
|
||||
|------|------|
|
||||
| 프론트엔드 | React 19, Vite 7, TypeScript 5.9 |
|
||||
| 지도 | MapLibre GL JS 5, Deck.gl 9 |
|
||||
| 인증 | Google OAuth (AuthProvider + ProtectedRoute) |
|
||||
| 백엔드 | Fastify 5, TypeScript |
|
||||
| 린트 | ESLint 9, Prettier |
|
||||
|
||||
|
||||
@ -131,52 +131,6 @@ function parseBbox(raw: string | undefined) {
|
||||
return { lonMin, latMin, lonMax, latMax };
|
||||
}
|
||||
|
||||
app.get<{
|
||||
Params: { mmsi: string };
|
||||
Querystring: { minutes?: string };
|
||||
}>("/api/ais-target/:mmsi/track", async (req, reply) => {
|
||||
const mmsiRaw = req.params.mmsi;
|
||||
const mmsi = Number(mmsiRaw);
|
||||
if (!Number.isFinite(mmsi) || mmsi <= 0 || !Number.isInteger(mmsi)) {
|
||||
return reply.code(400).send({ success: false, message: "invalid mmsi", data: [], errorCode: "BAD_REQUEST" });
|
||||
}
|
||||
|
||||
const minutesRaw = req.query.minutes ?? "360";
|
||||
const minutes = Number(minutesRaw);
|
||||
if (!Number.isFinite(minutes) || minutes <= 0 || minutes > 7200) {
|
||||
return reply.code(400).send({ success: false, message: "invalid minutes (1-7200)", data: [], errorCode: "BAD_REQUEST" });
|
||||
}
|
||||
|
||||
const u = new URL(`/snp-api/api/ais-target/${mmsi}/track`, AIS_UPSTREAM_BASE);
|
||||
u.searchParams.set("minutes", String(minutes));
|
||||
|
||||
const controller = new AbortController();
|
||||
const timeoutMs = 20_000;
|
||||
const timeout = setTimeout(() => controller.abort(), timeoutMs);
|
||||
try {
|
||||
const res = await fetch(u, { signal: controller.signal, headers: { accept: "application/json" } });
|
||||
const txt = await res.text();
|
||||
if (!res.ok) {
|
||||
req.log.warn({ status: res.status, body: txt.slice(0, 2000) }, "Track upstream error");
|
||||
return reply.code(502).send({ success: false, message: "upstream error", data: [], errorCode: "UPSTREAM" });
|
||||
}
|
||||
|
||||
reply.type("application/json").send(txt);
|
||||
} catch (e) {
|
||||
const name = e instanceof Error ? e.name : "";
|
||||
const isTimeout = name === "AbortError";
|
||||
req.log.warn({ err: e, url: u.toString() }, "Track proxy request failed");
|
||||
return reply.code(isTimeout ? 504 : 502).send({
|
||||
success: false,
|
||||
message: isTimeout ? `upstream timeout (${timeoutMs}ms)` : "upstream fetch failed",
|
||||
data: [],
|
||||
errorCode: isTimeout ? "UPSTREAM_TIMEOUT" : "UPSTREAM_FETCH_FAILED",
|
||||
});
|
||||
} finally {
|
||||
clearTimeout(timeout);
|
||||
}
|
||||
});
|
||||
|
||||
app.get("/zones", async (_req, reply) => {
|
||||
const zonesPath = path.resolve(
|
||||
process.cwd(),
|
||||
|
||||
@ -12,14 +12,11 @@
|
||||
"dependencies": {
|
||||
"@deck.gl/aggregation-layers": "^9.2.7",
|
||||
"@deck.gl/core": "^9.2.7",
|
||||
"@deck.gl/geo-layers": "^9.2.7",
|
||||
"@deck.gl/layers": "^9.2.7",
|
||||
"@deck.gl/mapbox": "^9.2.7",
|
||||
"@react-oauth/google": "^0.13.4",
|
||||
"maplibre-gl": "^5.18.0",
|
||||
"react": "^19.2.0",
|
||||
"react-dom": "^19.2.0",
|
||||
"react-router": "^7.13.0"
|
||||
"react-dom": "^19.2.0"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@eslint/js": "^9.39.1",
|
||||
|
||||
@ -1,23 +1,6 @@
|
||||
import { BrowserRouter, Route, Routes } from "react-router";
|
||||
import { AuthProvider, ProtectedRoute } from "../shared/auth";
|
||||
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() {
|
||||
return (
|
||||
<AuthProvider>
|
||||
<BrowserRouter>
|
||||
<Routes>
|
||||
<Route path="/login" element={<LoginPage />} />
|
||||
<Route path="/pending" element={<PendingPage />} />
|
||||
<Route path="/denied" element={<DeniedPage />} />
|
||||
<Route element={<ProtectedRoute />}>
|
||||
<Route index element={<DashboardPage />} />
|
||||
</Route>
|
||||
</Routes>
|
||||
</BrowserRouter>
|
||||
</AuthProvider>
|
||||
);
|
||||
return <DashboardPage />;
|
||||
}
|
||||
|
||||
|
||||
@ -105,7 +105,6 @@ body {
|
||||
|
||||
.map-area {
|
||||
position: relative;
|
||||
background: #010610;
|
||||
}
|
||||
|
||||
.sb {
|
||||
@ -1098,167 +1097,6 @@ body {
|
||||
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) {
|
||||
.app {
|
||||
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,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 { searchAisTargets } from "../../entities/aisTarget/api/searchAisTargets";
|
||||
import { searchChnprmship } from "../../entities/aisTarget/api/searchChnprmship";
|
||||
import type { AisTarget } from "../../entities/aisTarget/model/types";
|
||||
|
||||
export type AisPollingStatus = "idle" | "loading" | "ready" | "error";
|
||||
@ -18,21 +17,14 @@ export type AisPollingSnapshot = {
|
||||
};
|
||||
|
||||
export type AisPollingOptions = {
|
||||
/** 초기 chnprmship API 호출 시 minutes (기본 120) */
|
||||
chnprmshipMinutes?: number;
|
||||
/** 주기적 폴링 시 search API minutes (기본 2) */
|
||||
initialMinutes?: number;
|
||||
bootstrapMinutes?: number;
|
||||
incrementalMinutes?: number;
|
||||
/** 폴링 주기 ms (기본 60_000) */
|
||||
intervalMs?: number;
|
||||
/** 보존 기간 (기본 chnprmshipMinutes) */
|
||||
retentionMinutes?: number;
|
||||
/** incremental 폴링 시 bbox 필터 */
|
||||
bbox?: string;
|
||||
/** incremental 폴링 시 중심 경도 */
|
||||
centerLon?: number;
|
||||
/** incremental 폴링 시 중심 위도 */
|
||||
centerLat?: number;
|
||||
/** incremental 폴링 시 반경(m) */
|
||||
radiusMeters?: number;
|
||||
enabled?: boolean;
|
||||
};
|
||||
@ -120,10 +112,11 @@ function pruneStore(store: Map<number, AisTarget>, retentionMinutes: number, bbo
|
||||
}
|
||||
|
||||
export function useAisTargetPolling(opts: AisPollingOptions = {}) {
|
||||
const chnprmshipMinutes = opts.chnprmshipMinutes ?? 120;
|
||||
const incrementalMinutes = opts.incrementalMinutes ?? 2;
|
||||
const initialMinutes = opts.initialMinutes ?? 60;
|
||||
const bootstrapMinutes = opts.bootstrapMinutes ?? initialMinutes;
|
||||
const incrementalMinutes = opts.incrementalMinutes ?? 1;
|
||||
const intervalMs = opts.intervalMs ?? 60_000;
|
||||
const retentionMinutes = opts.retentionMinutes ?? chnprmshipMinutes;
|
||||
const retentionMinutes = opts.retentionMinutes ?? initialMinutes;
|
||||
const enabled = opts.enabled ?? true;
|
||||
const bbox = opts.bbox;
|
||||
const centerLon = opts.centerLon;
|
||||
@ -153,60 +146,50 @@ export function useAisTargetPolling(opts: AisPollingOptions = {}) {
|
||||
const controller = new AbortController();
|
||||
const generation = ++generationRef.current;
|
||||
|
||||
function applyResult(res: { data: AisTarget[]; message: string }, minutes: number) {
|
||||
if (cancelled || generation !== generationRef.current) return;
|
||||
|
||||
const { inserted, upserted } = upsertByMmsi(storeRef.current, res.data);
|
||||
const deleted = pruneStore(storeRef.current, retentionMinutes, bbox);
|
||||
const total = storeRef.current.size;
|
||||
|
||||
setSnapshot({
|
||||
status: "ready",
|
||||
error: null,
|
||||
lastFetchAt: new Date().toISOString(),
|
||||
lastFetchMinutes: minutes,
|
||||
lastMessage: res.message,
|
||||
total,
|
||||
lastUpserted: upserted,
|
||||
lastInserted: inserted,
|
||||
lastDeleted: deleted,
|
||||
});
|
||||
setRev((r) => r + 1);
|
||||
}
|
||||
|
||||
async function runInitial(minutes: number) {
|
||||
async function run(minutes: number, context: "bootstrap" | "initial" | "incremental") {
|
||||
try {
|
||||
setSnapshot((s) => ({ ...s, status: "loading", error: null }));
|
||||
const res = await searchChnprmship({ minutes }, controller.signal);
|
||||
applyResult(res, minutes);
|
||||
} catch (e) {
|
||||
if (cancelled || generation !== generationRef.current) return;
|
||||
setSnapshot((s) => ({
|
||||
...s,
|
||||
status: "error",
|
||||
error: e instanceof Error ? e.message : String(e),
|
||||
}));
|
||||
}
|
||||
}
|
||||
setSnapshot((s) => ({ ...s, status: s.status === "idle" ? "loading" : s.status, error: null }));
|
||||
|
||||
async function runIncremental(minutes: number) {
|
||||
try {
|
||||
setSnapshot((s) => ({ ...s, error: null }));
|
||||
const res = await searchAisTargets(
|
||||
{ minutes, bbox, centerLon, centerLat, radiusMeters },
|
||||
{
|
||||
minutes,
|
||||
bbox,
|
||||
centerLon,
|
||||
centerLat,
|
||||
radiusMeters,
|
||||
},
|
||||
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) {
|
||||
if (cancelled || generation !== generationRef.current) return;
|
||||
setSnapshot((s) => ({
|
||||
...s,
|
||||
status: context === "incremental" ? s.status : "error",
|
||||
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();
|
||||
setSnapshot({
|
||||
status: "loading",
|
||||
@ -221,11 +204,12 @@ export function useAisTargetPolling(opts: AisPollingOptions = {}) {
|
||||
});
|
||||
setRev((r) => r + 1);
|
||||
|
||||
// 초기 로드: chnprmship API 1회 호출
|
||||
void runInitial(chnprmshipMinutes);
|
||||
void run(bootstrapMinutes, "bootstrap");
|
||||
if (bootstrapMinutes !== initialMinutes) {
|
||||
void run(initialMinutes, "initial");
|
||||
}
|
||||
|
||||
// 주기적 폴링: search API로 incremental 업데이트
|
||||
const id = window.setInterval(() => void runIncremental(incrementalMinutes), intervalMs);
|
||||
const id = window.setInterval(() => void run(incrementalMinutes, "incremental"), intervalMs);
|
||||
|
||||
return () => {
|
||||
cancelled = true;
|
||||
@ -233,7 +217,8 @@ export function useAisTargetPolling(opts: AisPollingOptions = {}) {
|
||||
window.clearInterval(id);
|
||||
};
|
||||
}, [
|
||||
chnprmshipMinutes,
|
||||
initialMinutes,
|
||||
bootstrapMinutes,
|
||||
incrementalMinutes,
|
||||
intervalMs,
|
||||
retentionMinutes,
|
||||
|
||||
@ -1,6 +1,4 @@
|
||||
import { useCallback, useEffect, useMemo, useRef, useState } from "react";
|
||||
import { useAuth } from "../../shared/auth";
|
||||
import { usePersistedState } from "../../shared/hooks";
|
||||
import { useEffect, useMemo, useRef, useState } from "react";
|
||||
import { useAisTargetPolling } from "../../features/aisPolling/useAisTargetPolling";
|
||||
import { Map3DSettingsToggles } from "../../features/map3dSettings/Map3DSettingsToggles";
|
||||
import type { MapToggleState } from "../../features/mapToggles/MapToggles";
|
||||
@ -20,20 +18,16 @@ import { AisTargetList } from "../../widgets/aisTargetList/AisTargetList";
|
||||
import { AlarmsPanel } from "../../widgets/alarms/AlarmsPanel";
|
||||
import { MapLegend } from "../../widgets/legend/MapLegend";
|
||||
import { Map3D, type BaseMapId, type Map3DSettings, type MapProjectionId } from "../../widgets/map3d/Map3D";
|
||||
import type { MapViewState } from "../../widgets/map3d/types";
|
||||
import { RelationsPanel } from "../../widgets/relations/RelationsPanel";
|
||||
import { SpeedProfilePanel } from "../../widgets/speed/SpeedProfilePanel";
|
||||
import { Topbar } from "../../widgets/topbar/Topbar";
|
||||
import { VesselInfoPanel } from "../../widgets/info/VesselInfoPanel";
|
||||
import { SubcableInfoPanel } from "../../widgets/subcableInfo/SubcableInfoPanel";
|
||||
import { VesselList } from "../../widgets/vesselList/VesselList";
|
||||
import 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 {
|
||||
buildLegacyHitMap,
|
||||
computeCountsByType,
|
||||
@ -53,6 +47,13 @@ const AIS_CENTER = {
|
||||
radiusMeters: 2_000_000,
|
||||
};
|
||||
|
||||
function fmtLocal(iso: string | null) {
|
||||
if (!iso) return "-";
|
||||
const d = new Date(iso);
|
||||
if (Number.isNaN(d.getTime())) return iso;
|
||||
return d.toLocaleString("ko-KR", { hour12: false });
|
||||
}
|
||||
|
||||
type Bbox = [number, number, number, number]; // [lonMin, latMin, lonMax, latMax]
|
||||
type FleetRelationSortMode = "count" | "range";
|
||||
|
||||
@ -73,7 +74,6 @@ function useLegacyIndex(data: LegacyVesselDataset | null): LegacyVesselIndex | n
|
||||
}
|
||||
|
||||
export function DashboardPage() {
|
||||
const { user, logout } = useAuth();
|
||||
const { data: zones, error: zonesError } = useZones();
|
||||
const { data: legacyData, error: legacyError } = useLegacyVessels();
|
||||
const { data: subcableData } = useSubcables();
|
||||
@ -85,10 +85,11 @@ export function DashboardPage() {
|
||||
const [apiBbox, setApiBbox] = useState<string | undefined>(undefined);
|
||||
|
||||
const { targets, snapshot } = useAisTargetPolling({
|
||||
chnprmshipMinutes: 120,
|
||||
initialMinutes: 60,
|
||||
bootstrapMinutes: 10,
|
||||
incrementalMinutes: 2,
|
||||
intervalMs: 60_000,
|
||||
retentionMinutes: 120,
|
||||
retentionMinutes: 90,
|
||||
bbox: useApiBbox ? apiBbox : undefined,
|
||||
centerLon: useApiBbox ? undefined : AIS_CENTER.lon,
|
||||
centerLat: useApiBbox ? undefined : AIS_CENTER.lat,
|
||||
@ -101,77 +102,55 @@ export function DashboardPage() {
|
||||
const [hoveredFleetMmsiSet, setHoveredFleetMmsiSet] = useState<number[]>([]);
|
||||
const [hoveredPairMmsiSet, setHoveredPairMmsiSet] = useState<number[]>([]);
|
||||
const [hoveredFleetOwnerKey, setHoveredFleetOwnerKey] = useState<string | null>(null);
|
||||
const uid = user?.id ?? null;
|
||||
const [typeEnabled, setTypeEnabled] = usePersistedState<Record<VesselTypeCode, boolean>>(
|
||||
uid, 'typeEnabled', { PT: true, "PT-S": true, GN: true, OT: true, PS: true, FC: true },
|
||||
);
|
||||
const [showTargets, setShowTargets] = usePersistedState(uid, 'showTargets', true);
|
||||
const [showOthers, setShowOthers] = usePersistedState(uid, 'showOthers', false);
|
||||
const [typeEnabled, setTypeEnabled] = useState<Record<VesselTypeCode, boolean>>({
|
||||
PT: true,
|
||||
"PT-S": true,
|
||||
GN: true,
|
||||
OT: true,
|
||||
PS: true,
|
||||
FC: true,
|
||||
});
|
||||
const [showTargets, setShowTargets] = useState(true);
|
||||
const [showOthers, setShowOthers] = useState(false);
|
||||
|
||||
// 레거시 베이스맵 비활성 — 향후 위성/라이트 등 추가 시 재활용
|
||||
// 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 [projection, setProjection] = useState<MapProjectionId>("mercator");
|
||||
const [mapStyleSettings, setMapStyleSettings] = useState<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 [overlays, setOverlays] = useState<MapToggleState>({
|
||||
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 [fleetRelationSortMode, setFleetRelationSortMode] = useState<FleetRelationSortMode>("count");
|
||||
|
||||
const [alarmKindEnabled, setAlarmKindEnabled] = usePersistedState<Record<LegacyAlarmKind, boolean>>(
|
||||
uid, 'alarmKindEnabled',
|
||||
() => Object.fromEntries(LEGACY_ALARM_KINDS.map((k) => [k, true])) as Record<LegacyAlarmKind, boolean>,
|
||||
);
|
||||
const [alarmKindEnabled, setAlarmKindEnabled] = useState<Record<LegacyAlarmKind, boolean>>(() => {
|
||||
return Object.fromEntries(LEGACY_ALARM_KINDS.map((k) => [k, true])) as Record<LegacyAlarmKind, boolean>;
|
||||
});
|
||||
|
||||
const [fleetFocus, setFleetFocus] = useState<{ id: string | number; center: [number, number]; zoom?: number } | undefined>(undefined);
|
||||
|
||||
const [hoveredCableId, setHoveredCableId] = useState<string | null>(null);
|
||||
const [selectedCableId, setSelectedCableId] = useState<string | null>(null);
|
||||
|
||||
// 항적 (vessel track)
|
||||
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 [settings, setSettings] = useState<Map3DSettings>({
|
||||
showShips: true,
|
||||
showDensity: false,
|
||||
showSeamark: false,
|
||||
});
|
||||
const [mapView, setMapView] = usePersistedState<MapViewState | null>(uid, 'mapView', null);
|
||||
|
||||
const [isProjectionLoading, setIsProjectionLoading] = useState(false);
|
||||
// 초기값 false: globe 레이어가 백그라운드에서 준비될 때까지 토글 비활성화
|
||||
const [isGlobeShipsReady, setIsGlobeShipsReady] = useState(false);
|
||||
const handleProjectionLoadingChange = useCallback((loading: boolean) => {
|
||||
setIsProjectionLoading(loading);
|
||||
}, []);
|
||||
const showMapLoader = isProjectionLoading;
|
||||
// globe 레이어 미준비 또는 전환 중일 때 토글 비활성화
|
||||
const isProjectionToggleDisabled = !isGlobeShipsReady || isProjectionLoading;
|
||||
|
||||
const [clock, setClock] = useState(() => fmtDateTimeFull(new Date()));
|
||||
const [clock, setClock] = useState(() => new Date().toLocaleString("ko-KR", { hour12: false }));
|
||||
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);
|
||||
}, []);
|
||||
|
||||
@ -331,8 +310,6 @@ export function DashboardPage() {
|
||||
clock={clock}
|
||||
adminMode={adminMode}
|
||||
onLogoClick={onLogoClick}
|
||||
userName={user?.name}
|
||||
onLogout={logout}
|
||||
/>
|
||||
|
||||
<div className="sidebar">
|
||||
@ -386,10 +363,10 @@ export function DashboardPage() {
|
||||
지도 표시 설정
|
||||
<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" }}
|
||||
className={`tog-btn ${projection === "globe" ? "on" : ""}`}
|
||||
onClick={() => setProjection((p) => (p === "globe" ? "mercator" : "globe"))}
|
||||
title="3D 지구본 투영: 드래그로 회전, 휠로 확대/축소"
|
||||
style={{ fontSize: 9, padding: "2px 8px" }}
|
||||
>
|
||||
3D
|
||||
</div>
|
||||
@ -566,7 +543,7 @@ export function DashboardPage() {
|
||||
</div>
|
||||
<div style={{ marginTop: 6, color: "var(--muted)", fontSize: 10 }}>최근 fetch</div>
|
||||
<div>
|
||||
{fmtIsoFull(snapshot.lastFetchAt)}{" "}
|
||||
{fmtLocal(snapshot.lastFetchAt)}{" "}
|
||||
<span style={{ color: "var(--muted)", fontSize: 10 }}>
|
||||
({snapshot.lastFetchMinutes ?? "-"}m, inserted {snapshot.lastInserted}, upserted {snapshot.lastUpserted})
|
||||
</span>
|
||||
@ -590,7 +567,7 @@ export function DashboardPage() {
|
||||
<span style={{ color: "var(--muted)", fontSize: 10 }}>/ {targetsInScope.length}</span>
|
||||
</div>
|
||||
<div style={{ marginTop: 6, color: "var(--muted)", fontSize: 10 }}>생성시각</div>
|
||||
<div style={{ fontSize: 10, color: "var(--text)" }}>{legacyData?.generatedAt ? fmtIsoFull(legacyData.generatedAt) : "loading..."}</div>
|
||||
<div style={{ fontSize: 10, color: "var(--text)" }}>{legacyData?.generatedAt ? fmtLocal(legacyData.generatedAt) : "loading..."}</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
@ -701,7 +678,7 @@ export function DashboardPage() {
|
||||
</div>
|
||||
|
||||
<div className="map-area">
|
||||
{showMapLoader ? (
|
||||
{isProjectionLoading ? (
|
||||
<div className="map-loader-overlay" role="status" aria-live="polite">
|
||||
<div className="map-loader-overlay__panel">
|
||||
<div className="map-loader-overlay__spinner" />
|
||||
@ -733,8 +710,7 @@ export function DashboardPage() {
|
||||
fcLinks={fcLinksForMap}
|
||||
fleetCircles={fleetCirclesForMap}
|
||||
fleetFocus={fleetFocus}
|
||||
onProjectionLoadingChange={handleProjectionLoadingChange}
|
||||
onGlobeShipsReady={setIsGlobeShipsReady}
|
||||
onProjectionLoadingChange={setIsProjectionLoading}
|
||||
onHoverMmsi={(mmsis) => setHoveredMmsiSet(setUniqueSorted(mmsis))}
|
||||
onClearMmsiHover={() => setHoveredMmsiSet((prev) => (prev.length === 0 ? prev : []))}
|
||||
onHoverPair={(pairMmsis) => setHoveredPairMmsiSet(setUniqueSorted(pairMmsis))}
|
||||
@ -752,13 +728,6 @@ export function DashboardPage() {
|
||||
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} />
|
||||
|
||||
@ -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">🚫</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 · 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">⏳</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 { LegacyVesselInfo } from "../../entities/legacyVessel/model/types";
|
||||
import { fmtIsoFull } from "../../shared/lib/datetime";
|
||||
|
||||
type Props = {
|
||||
target: AisTarget;
|
||||
@ -86,11 +85,11 @@ export function AisInfoPanel({ target: t, legacy, onClose }: Props) {
|
||||
</div>
|
||||
<div className="ir">
|
||||
<span className="il">Msg TS</span>
|
||||
<span className="iv">{fmtIsoFull(t.messageTimestamp)}</span>
|
||||
<span className="iv">{t.messageTimestamp || "-"}</span>
|
||||
</div>
|
||||
<div className="ir">
|
||||
<span className="il">Received</span>
|
||||
<span className="iv">{fmtIsoFull(t.receivedDate)}</span>
|
||||
<span className="iv">{t.receivedDate || "-"}</span>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
|
||||
@ -2,7 +2,6 @@ import { useMemo, useState } from "react";
|
||||
import type { AisTarget } from "../../entities/aisTarget/model/types";
|
||||
import type { LegacyVesselIndex } from "../../entities/legacyVessel/lib";
|
||||
import { matchLegacyVessel } from "../../entities/legacyVessel/lib";
|
||||
import { fmtIsoTime } from "../../shared/lib/datetime";
|
||||
|
||||
type SortMode = "recent" | "speed";
|
||||
|
||||
@ -24,6 +23,13 @@ function getSpeedColor(sog: unknown) {
|
||||
return "#64748B";
|
||||
}
|
||||
|
||||
function fmtLocalTime(iso: string | null | undefined) {
|
||||
if (!iso) return "";
|
||||
const d = new Date(iso);
|
||||
if (Number.isNaN(d.getTime())) return String(iso);
|
||||
return d.toLocaleTimeString("ko-KR", { hour12: false });
|
||||
}
|
||||
|
||||
export function AisTargetList({ targets, selectedMmsi, onSelectMmsi, legacyIndex }: Props) {
|
||||
const [q, setQ] = useState("");
|
||||
const [mode, setMode] = useState<SortMode>("recent");
|
||||
@ -90,7 +96,7 @@ export function AisTargetList({ targets, selectedMmsi, onSelectMmsi, legacyIndex
|
||||
const sel = selectedMmsi && t.mmsi === selectedMmsi;
|
||||
const sp = isFiniteNumber(t.sog) ? t.sog.toFixed(1) : "?";
|
||||
const sc = getSpeedColor(t.sog);
|
||||
const ts = fmtIsoTime(t.messageTimestamp);
|
||||
const ts = fmtLocalTime(t.messageTimestamp);
|
||||
const legacy = legacyIndex ? matchLegacyVessel(t, legacyIndex) : null;
|
||||
const legacyCode = legacy?.shipCode || "";
|
||||
|
||||
|
||||
@ -1,7 +1,6 @@
|
||||
import { ZONE_META } from "../../entities/zone/model/meta";
|
||||
import { SPEED_MAX, VESSEL_TYPES } from "../../entities/vessel/model/meta";
|
||||
import type { DerivedLegacyVessel } from "../../features/legacyDashboard/model/types";
|
||||
import { fmtIsoFull } from "../../shared/lib/datetime";
|
||||
import { haversineNm } from "../../shared/lib/geo/haversineNm";
|
||||
import { OVERLAY_RGB, rgbToHex } from "../../shared/lib/map/palette";
|
||||
|
||||
@ -76,7 +75,7 @@ export function VesselInfoPanel({ vessel: v, allVessels, onClose, onSelectMmsi }
|
||||
</div>
|
||||
<div className="ir">
|
||||
<span className="il">Msg TS</span>
|
||||
<span className="iv">{fmtIsoFull(v.messageTimestamp)}</span>
|
||||
<span className="iv">{v.messageTimestamp || "-"}</span>
|
||||
</div>
|
||||
<div className="ir">
|
||||
<span className="il">소유주</span>
|
||||
|
||||
@ -26,9 +26,7 @@ import { useGlobeOverlays } from './hooks/useGlobeOverlays';
|
||||
import { useGlobeInteraction } from './hooks/useGlobeInteraction';
|
||||
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';
|
||||
|
||||
@ -68,14 +66,6 @@ export function Map3D({
|
||||
onHoverCable,
|
||||
onClickCable,
|
||||
mapStyleSettings,
|
||||
initialView,
|
||||
onViewStateChange,
|
||||
onGlobeShipsReady,
|
||||
activeTrack = null,
|
||||
trackContextMenu = null,
|
||||
onRequestTrack,
|
||||
onCloseTrackMenu,
|
||||
onOpenTrackMenu,
|
||||
}: Props) {
|
||||
void onHoverFleet;
|
||||
void onClearFleetHover;
|
||||
@ -101,7 +91,6 @@ export function Map3D({
|
||||
|
||||
// ── Hover state ──────────────────────────────────────────────────────
|
||||
const {
|
||||
hoveredDeckMmsiSet: hoveredDeckMmsiArr,
|
||||
setHoveredDeckMmsiSet,
|
||||
setHoveredDeckPairMmsiSet,
|
||||
setHoveredDeckFleetOwnerKey,
|
||||
@ -445,15 +434,15 @@ export function Map3D({
|
||||
}, [fleetCircles, hoveredFleetOwnerKeys, isHighlightedFleet, overlays.fleetCircles, highlightedMmsiSetCombined]);
|
||||
|
||||
// ── Hook orchestration ───────────────────────────────────────────────
|
||||
const { ensureMercatorOverlay, pulseMapSync } = useMapInit(
|
||||
const { ensureMercatorOverlay, clearGlobeNativeLayers, pulseMapSync } = useMapInit(
|
||||
containerRef, mapRef, overlayRef, overlayInteractionRef, globeDeckLayerRef,
|
||||
baseMapRef, projectionRef,
|
||||
{ baseMap, projection, showSeamark: settings.showSeamark, onViewBboxChange, setMapSyncEpoch, initialView, onViewStateChange },
|
||||
{ baseMap, projection, showSeamark: settings.showSeamark, onViewBboxChange, setMapSyncEpoch },
|
||||
);
|
||||
|
||||
const reorderGlobeFeatureLayers = useProjectionToggle(
|
||||
mapRef, overlayRef, overlayInteractionRef, globeDeckLayerRef, projectionBusyRef,
|
||||
{ projection, ensureMercatorOverlay, onProjectionLoadingChange, pulseMapSync, setMapSyncEpoch },
|
||||
{ projection, clearGlobeNativeLayers, ensureMercatorOverlay, onProjectionLoadingChange, pulseMapSync, setMapSyncEpoch },
|
||||
);
|
||||
|
||||
useBaseMapToggle(
|
||||
@ -483,7 +472,6 @@ export function Map3D({
|
||||
shipOverlayLayerData, shipLayerData, shipByMmsi, mapSyncEpoch,
|
||||
onSelectMmsi, onToggleHighlightMmsi, targets, overlays,
|
||||
legacyHits, selectedMmsi, isBaseHighlightedMmsi, hasAuxiliarySelectModifier,
|
||||
onGlobeShipsReady,
|
||||
},
|
||||
);
|
||||
|
||||
@ -535,80 +523,10 @@ export function Map3D({
|
||||
},
|
||||
);
|
||||
|
||||
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(
|
||||
mapRef, projectionRef,
|
||||
{ selectedMmsi, shipData, fleetFocusId, fleetFocusLon, fleetFocusLat, fleetFocusZoom },
|
||||
);
|
||||
|
||||
return (
|
||||
<>
|
||||
<div ref={containerRef} style={{ width: '100%', height: '100%' }} />
|
||||
{trackContextMenu && onRequestTrack && onCloseTrackMenu && (
|
||||
<VesselContextMenu
|
||||
x={trackContextMenu.x}
|
||||
y={trackContextMenu.y}
|
||||
mmsi={trackContextMenu.mmsi}
|
||||
vesselName={trackContextMenu.vesselName}
|
||||
onRequestTrack={onRequestTrack}
|
||||
onClose={onCloseTrackMenu}
|
||||
/>
|
||||
)}
|
||||
</>
|
||||
);
|
||||
return <div ref={containerRef} style={{ width: '100%', height: '100%' }} />;
|
||||
}
|
||||
|
||||
@ -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>
|
||||
);
|
||||
}
|
||||
@ -110,7 +110,7 @@ export function useBaseMapToggle(
|
||||
if (!map) return;
|
||||
if (showSeamark) {
|
||||
try {
|
||||
ensureSeamarkOverlay(map, 'bathymetry-lines-coarse');
|
||||
ensureSeamarkOverlay(map, 'bathymetry-lines');
|
||||
map.setPaintProperty('seamark', 'raster-opacity', 0.85);
|
||||
} catch {
|
||||
// ignore until style is ready
|
||||
|
||||
@ -50,13 +50,6 @@ import {
|
||||
getFleetCircleTooltipHtml,
|
||||
} from '../lib/tooltips';
|
||||
import { sanitizeDeckLayerList } from '../lib/mapCore';
|
||||
import { getCachedShipIcon } from '../lib/shipIconCache';
|
||||
|
||||
// NOTE:
|
||||
// Globe mode now relies on MapLibre native overlays (useGlobeOverlays/useGlobeShips).
|
||||
// Keep Deck custom-layer rendering disabled in globe to avoid projection-space artifacts.
|
||||
const ENABLE_GLOBE_DECK_OVERLAYS = false;
|
||||
|
||||
|
||||
export function useDeckLayers(
|
||||
mapRef: MutableRefObject<import('maplibre-gl').Map | null>,
|
||||
@ -381,7 +374,7 @@ export function useDeckLayers(
|
||||
pickable: true,
|
||||
billboard: false,
|
||||
parameters: overlayParams,
|
||||
iconAtlas: getCachedShipIcon(),
|
||||
iconAtlas: '/assets/ship.svg',
|
||||
iconMapping: SHIP_ICON_MAPPING,
|
||||
getIcon: () => 'ship',
|
||||
getPosition: (d) => [d.lon, d.lat] as [number, number],
|
||||
@ -404,7 +397,7 @@ export function useDeckLayers(
|
||||
pickable: false,
|
||||
billboard: false,
|
||||
parameters: overlayParams,
|
||||
iconAtlas: getCachedShipIcon(),
|
||||
iconAtlas: '/assets/ship.svg',
|
||||
iconMapping: SHIP_ICON_MAPPING,
|
||||
getIcon: () => 'ship',
|
||||
getPosition: (d) => [d.lon, d.lat] as [number, number],
|
||||
@ -449,7 +442,7 @@ export function useDeckLayers(
|
||||
pickable: true,
|
||||
billboard: false,
|
||||
parameters: overlayParams,
|
||||
iconAtlas: getCachedShipIcon(),
|
||||
iconAtlas: '/assets/ship.svg',
|
||||
iconMapping: SHIP_ICON_MAPPING,
|
||||
getIcon: () => 'ship',
|
||||
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) {
|
||||
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);
|
||||
@ -602,16 +595,6 @@ export function useDeckLayers(
|
||||
const deckTarget = globeDeckLayerRef.current;
|
||||
if (!deckTarget) return;
|
||||
|
||||
if (!ENABLE_GLOBE_DECK_OVERLAYS) {
|
||||
try {
|
||||
deckTarget.setProps({ layers: [], getTooltip: undefined, onClick: undefined } as never);
|
||||
} catch {
|
||||
// ignore
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
|
||||
const overlayParams = GLOBE_OVERLAY_PARAMS;
|
||||
const globeLayers: unknown[] = [];
|
||||
|
||||
|
||||
@ -118,7 +118,7 @@ export function useGlobeInteraction(
|
||||
});
|
||||
}
|
||||
|
||||
if (layerId === 'fleet-circles-ml') {
|
||||
if (layerId === 'fleet-circles-ml' || layerId === 'fleet-circles-ml-fill') {
|
||||
return getFleetCircleTooltipHtml({
|
||||
ownerKey: String(props.ownerKey ?? ''),
|
||||
ownerLabel: String(props.ownerLabel ?? props.ownerKey ?? ''),
|
||||
@ -186,7 +186,7 @@ export function useGlobeInteraction(
|
||||
candidateLayerIds = [
|
||||
'ships-globe', 'ships-globe-halo', 'ships-globe-outline',
|
||||
'pair-lines-ml', 'fc-lines-ml',
|
||||
'fleet-circles-ml',
|
||||
'fleet-circles-ml', 'fleet-circles-ml-fill',
|
||||
'pair-range-ml',
|
||||
'zones-fill', 'zones-line', 'zones-label',
|
||||
].filter((id) => map.getLayer(id));
|
||||
@ -213,7 +213,7 @@ export function useGlobeInteraction(
|
||||
const priority = [
|
||||
'ships-globe', 'ships-globe-halo', 'ships-globe-outline',
|
||||
'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',
|
||||
];
|
||||
|
||||
@ -232,7 +232,7 @@ export function useGlobeInteraction(
|
||||
const isShipLayer = layerId === 'ships-globe' || layerId === 'ships-globe-halo' || layerId === 'ships-globe-outline';
|
||||
const isPairLayer = layerId === 'pair-lines-ml' || layerId === 'pair-range-ml';
|
||||
const isFcLayer = layerId === 'fc-lines-ml';
|
||||
const isFleetLayer = layerId === 'fleet-circles-ml';
|
||||
const isFleetLayer = layerId === 'fleet-circles-ml' || layerId === 'fleet-circles-ml-fill';
|
||||
const isZoneLayer = layerId === 'zones-fill' || layerId === 'zones-line' || layerId === 'zones-label';
|
||||
|
||||
if (isShipLayer) {
|
||||
|
||||
@ -11,6 +11,7 @@ import {
|
||||
PAIR_RANGE_NORMAL_ML_HL, PAIR_RANGE_WARN_ML_HL,
|
||||
FC_LINE_NORMAL_ML, FC_LINE_SUSPICIOUS_ML,
|
||||
FC_LINE_NORMAL_ML_HL, FC_LINE_SUSPICIOUS_ML_HL,
|
||||
FLEET_FILL_ML, FLEET_FILL_ML_HL,
|
||||
FLEET_LINE_ML, FLEET_LINE_ML_HL,
|
||||
} from '../constants';
|
||||
import { makeUniqueSorted } from '../lib/setUtils';
|
||||
@ -27,7 +28,6 @@ import {
|
||||
} from '../lib/mlExpressions';
|
||||
import { kickRepaint, onMapStyleReady } from '../lib/mapCore';
|
||||
import { circleRingLngLat } from '../lib/geometry';
|
||||
import { guardedSetVisibility } from '../lib/layerHelpers';
|
||||
import { dashifyLine } from '../lib/dashifyLine';
|
||||
|
||||
export function useGlobeOverlays(
|
||||
@ -60,7 +60,11 @@ export function useGlobeOverlays(
|
||||
const layerId = 'pair-lines-ml';
|
||||
|
||||
const remove = () => {
|
||||
guardedSetVisibility(map, layerId, 'none');
|
||||
try {
|
||||
if (map.getLayer(layerId)) map.setLayoutProperty(layerId, 'visibility', 'none');
|
||||
} catch {
|
||||
// ignore
|
||||
}
|
||||
};
|
||||
|
||||
const ensure = () => {
|
||||
@ -128,7 +132,11 @@ export function useGlobeOverlays(
|
||||
console.warn('Pair lines layer add failed:', e);
|
||||
}
|
||||
} else {
|
||||
guardedSetVisibility(map, layerId, 'visible');
|
||||
try {
|
||||
map.setLayoutProperty(layerId, 'visibility', 'visible');
|
||||
} catch {
|
||||
// ignore
|
||||
}
|
||||
}
|
||||
|
||||
reorderGlobeFeatureLayers();
|
||||
@ -151,7 +159,11 @@ export function useGlobeOverlays(
|
||||
const layerId = 'fc-lines-ml';
|
||||
|
||||
const remove = () => {
|
||||
guardedSetVisibility(map, layerId, 'none');
|
||||
try {
|
||||
if (map.getLayer(layerId)) map.setLayoutProperty(layerId, 'visibility', 'none');
|
||||
} catch {
|
||||
// ignore
|
||||
}
|
||||
};
|
||||
|
||||
const ensure = () => {
|
||||
@ -223,7 +235,11 @@ export function useGlobeOverlays(
|
||||
console.warn('FC lines layer add failed:', e);
|
||||
}
|
||||
} else {
|
||||
guardedSetVisibility(map, layerId, 'visible');
|
||||
try {
|
||||
map.setLayoutProperty(layerId, 'visibility', 'visible');
|
||||
} catch {
|
||||
// ignore
|
||||
}
|
||||
}
|
||||
|
||||
reorderGlobeFeatureLayers();
|
||||
@ -243,13 +259,21 @@ export function useGlobeOverlays(
|
||||
if (!map) return;
|
||||
|
||||
const srcId = 'fleet-circles-ml-src';
|
||||
const fillSrcId = 'fleet-circles-ml-fill-src';
|
||||
const layerId = 'fleet-circles-ml';
|
||||
|
||||
// fill layer 제거됨 — globe tessellation에서 vertex 65535 초과 경고 원인
|
||||
// 라인만으로 fleet circle 시각화 충분
|
||||
const fillLayerId = 'fleet-circles-ml-fill';
|
||||
|
||||
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 = () => {
|
||||
@ -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 {
|
||||
const existing = map.getSource(srcId) as GeoJSONSource | undefined;
|
||||
if (existing) existing.setData(fcLine);
|
||||
@ -289,6 +333,41 @@ export function useGlobeOverlays(
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
const existingFill = map.getSource(fillSrcId) as GeoJSONSource | undefined;
|
||||
if (existingFill) existingFill.setData(fcFill);
|
||||
else map.addSource(fillSrcId, { type: 'geojson', data: fcFill } as GeoJSONSourceSpecification);
|
||||
} catch (e) {
|
||||
console.warn('Fleet circles source setup failed:', e);
|
||||
return;
|
||||
}
|
||||
|
||||
if (!map.getLayer(fillLayerId)) {
|
||||
try {
|
||||
map.addLayer(
|
||||
{
|
||||
id: fillLayerId,
|
||||
type: 'fill',
|
||||
source: fillSrcId,
|
||||
layout: { visibility: 'visible' },
|
||||
paint: {
|
||||
'fill-color': ['case', ['==', ['get', 'highlighted'], 1], FLEET_FILL_ML_HL, FLEET_FILL_ML] as never,
|
||||
'fill-opacity': ['case', ['==', ['get', 'highlighted'], 1], 0.7, 0.36] as never,
|
||||
},
|
||||
} as unknown as LayerSpecification,
|
||||
undefined,
|
||||
);
|
||||
} catch (e) {
|
||||
console.warn('Fleet circles fill layer add failed:', e);
|
||||
}
|
||||
} else {
|
||||
try {
|
||||
map.setLayoutProperty(fillLayerId, 'visibility', 'visible');
|
||||
} catch {
|
||||
// ignore
|
||||
}
|
||||
}
|
||||
|
||||
if (!map.getLayer(layerId)) {
|
||||
try {
|
||||
map.addLayer(
|
||||
@ -309,7 +388,11 @@ export function useGlobeOverlays(
|
||||
console.warn('Fleet circles layer add failed:', e);
|
||||
}
|
||||
} else {
|
||||
guardedSetVisibility(map, layerId, 'visible');
|
||||
try {
|
||||
map.setLayoutProperty(layerId, 'visibility', 'visible');
|
||||
} catch {
|
||||
// ignore
|
||||
}
|
||||
}
|
||||
|
||||
reorderGlobeFeatureLayers();
|
||||
@ -332,7 +415,11 @@ export function useGlobeOverlays(
|
||||
const layerId = 'pair-range-ml';
|
||||
|
||||
const remove = () => {
|
||||
guardedSetVisibility(map, layerId, 'none');
|
||||
try {
|
||||
if (map.getLayer(layerId)) map.setLayoutProperty(layerId, 'visibility', 'none');
|
||||
} catch {
|
||||
// ignore
|
||||
}
|
||||
};
|
||||
|
||||
const ensure = () => {
|
||||
@ -416,7 +503,11 @@ export function useGlobeOverlays(
|
||||
console.warn('Pair range layer add failed:', e);
|
||||
}
|
||||
} else {
|
||||
guardedSetVisibility(map, layerId, 'visible');
|
||||
try {
|
||||
map.setLayoutProperty(layerId, 'visibility', 'visible');
|
||||
} catch {
|
||||
// ignore
|
||||
}
|
||||
}
|
||||
|
||||
kickRepaint(map);
|
||||
@ -502,7 +593,10 @@ export function useGlobeOverlays(
|
||||
}
|
||||
|
||||
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')) {
|
||||
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);
|
||||
|
||||
@ -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 { GeoJSONSource, GeoJSONSourceSpecification, LayerSpecification } from 'maplibre-gl';
|
||||
import type { AisTarget } from '../../../entities/aisTarget/model/types';
|
||||
@ -25,7 +25,6 @@ import {
|
||||
ensureFallbackShipImage,
|
||||
} from '../lib/globeShipIcon';
|
||||
import { clampNumber } from '../lib/geometry';
|
||||
import { guardedSetVisibility } from '../lib/layerHelpers';
|
||||
|
||||
export function useGlobeShips(
|
||||
mapRef: MutableRefObject<maplibregl.Map | null>,
|
||||
@ -49,81 +48,18 @@ export function useGlobeShips(
|
||||
selectedMmsi: number | null;
|
||||
isBaseHighlightedMmsi: (mmsi: number) => boolean;
|
||||
hasAuxiliarySelectModifier: (ev?: { shiftKey?: boolean; ctrlKey?: boolean; metaKey?: boolean } | null) => boolean;
|
||||
onGlobeShipsReady?: (ready: boolean) => void;
|
||||
},
|
||||
) {
|
||||
const {
|
||||
projection, settings, shipData, shipHighlightSet, shipHoverOverlaySet,
|
||||
shipLayerData, mapSyncEpoch, onSelectMmsi, onToggleHighlightMmsi, targets,
|
||||
overlays, legacyHits, selectedMmsi, isBaseHighlightedMmsi, hasAuxiliarySelectModifier,
|
||||
onGlobeShipsReady,
|
||||
} = opts;
|
||||
|
||||
const globeShipsEpochRef = useRef(-1);
|
||||
const globeShipIconLoadingRef = useRef(false);
|
||||
const globeHoverShipSignatureRef = useRef('');
|
||||
|
||||
// Globe GeoJSON을 projection과 무관하게 항상 사전 계산
|
||||
// Globe 전환 시 즉시 사용 가능하도록 useMemo로 캐싱
|
||||
const globeShipGeoJson = useMemo((): GeoJSON.FeatureCollection<GeoJSON.Point> => {
|
||||
return {
|
||||
type: 'FeatureCollection',
|
||||
features: shipData.map((t) => {
|
||||
const legacy = legacyHits?.get(t.mmsi) ?? null;
|
||||
const labelName = legacy?.shipNameCn || legacy?.shipNameRoman || t.name || '';
|
||||
const heading = getDisplayHeading({
|
||||
cog: t.cog,
|
||||
heading: t.heading,
|
||||
offset: GLOBE_ICON_HEADING_OFFSET_DEG,
|
||||
});
|
||||
const isAnchored = isAnchoredShip({ sog: t.sog, cog: t.cog, heading: t.heading });
|
||||
const shipHeading = isAnchored ? 0 : heading;
|
||||
const hull = clampNumber(
|
||||
(isFiniteNumber(t.length) ? t.length : 0) + (isFiniteNumber(t.width) ? t.width : 0) * 3,
|
||||
50, 420,
|
||||
);
|
||||
const sizeScale = clampNumber(0.85 + (hull - 50) / 420, 0.82, 1.85);
|
||||
const selected = t.mmsi === selectedMmsi;
|
||||
const highlighted = isBaseHighlightedMmsi(t.mmsi);
|
||||
const selectedScale = selected ? 1.08 : 1;
|
||||
const highlightScale = highlighted ? 1.06 : 1;
|
||||
const iconScale = selected ? selectedScale : highlightScale;
|
||||
const iconSize3 = clampNumber(0.35 * sizeScale * selectedScale, 0.25, 1.3);
|
||||
const iconSize7 = clampNumber(0.45 * sizeScale * selectedScale, 0.3, 1.45);
|
||||
const iconSize10 = clampNumber(0.58 * sizeScale * selectedScale, 0.35, 1.8);
|
||||
const iconSize14 = clampNumber(0.85 * sizeScale * selectedScale, 0.45, 2.6);
|
||||
const iconSize18 = clampNumber(2.5 * sizeScale * selectedScale, 1.0, 6.0);
|
||||
return {
|
||||
type: 'Feature' as const,
|
||||
...(isFiniteNumber(t.mmsi) ? { id: Math.trunc(t.mmsi) } : {}),
|
||||
geometry: { type: 'Point' as const, coordinates: [t.lon, t.lat] },
|
||||
properties: {
|
||||
mmsi: t.mmsi,
|
||||
name: t.name || '',
|
||||
labelName,
|
||||
cog: shipHeading,
|
||||
heading: shipHeading,
|
||||
sog: isFiniteNumber(t.sog) ? t.sog : 0,
|
||||
isAnchored: isAnchored ? 1 : 0,
|
||||
shipColor: getGlobeBaseShipColor({
|
||||
legacy: legacy?.shipCode || null,
|
||||
sog: isFiniteNumber(t.sog) ? t.sog : null,
|
||||
}),
|
||||
iconSize3: iconSize3 * iconScale,
|
||||
iconSize7: iconSize7 * iconScale,
|
||||
iconSize10: iconSize10 * iconScale,
|
||||
iconSize14: iconSize14 * iconScale,
|
||||
iconSize18: iconSize18 * iconScale,
|
||||
sizeScale,
|
||||
selected: selected ? 1 : 0,
|
||||
highlighted: highlighted ? 1 : 0,
|
||||
permitted: legacy ? 1 : 0,
|
||||
code: legacy?.shipCode || '',
|
||||
},
|
||||
};
|
||||
}),
|
||||
};
|
||||
}, [shipData, legacyHits, selectedMmsi, isBaseHighlightedMmsi]);
|
||||
|
||||
// Ship name labels in mercator
|
||||
useEffect(() => {
|
||||
const map = mapRef.current;
|
||||
@ -273,52 +209,110 @@ export function useGlobeShips(
|
||||
const symbolId = 'ships-globe';
|
||||
const labelId = 'ships-globe-label';
|
||||
|
||||
// 레이어를 제거하지 않고 visibility만 'none'으로 설정
|
||||
// guardedSetVisibility로 현재 값과 동일하면 호출 생략 (style._changed 방지)
|
||||
const hide = () => {
|
||||
const remove = () => {
|
||||
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 = () => {
|
||||
ensureFallbackShipImage(map, imgId);
|
||||
ensureFallbackShipImage(map, anchoredImgId, buildFallbackGlobeAnchoredShipIcon);
|
||||
if (globeShipIconLoadingRef.current) 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 = () => {
|
||||
if (!settings.showShips) {
|
||||
hide();
|
||||
onGlobeShipsReady?.(false);
|
||||
return;
|
||||
}
|
||||
|
||||
// 빠른 visibility 토글 — projectionBusy 중에도 실행
|
||||
// guardedSetVisibility로 값이 실제로 바뀔 때만 setLayoutProperty 호출
|
||||
// → style._changed 방지 → 불필요한 symbol placement 재계산 방지 → 라벨 사라짐 방지
|
||||
const visibility: 'visible' | 'none' = projection === 'globe' ? 'visible' : 'none';
|
||||
const labelVisibility: 'visible' | 'none' = projection === 'globe' && overlays.shipLabels ? 'visible' : 'none';
|
||||
if (map.getLayer(symbolId)) {
|
||||
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 (projectionBusyRef.current) return;
|
||||
if (!map.isStyleLoaded()) return;
|
||||
|
||||
if (projection !== 'globe' || !settings.showShips) {
|
||||
remove();
|
||||
return;
|
||||
}
|
||||
|
||||
if (globeShipsEpochRef.current !== mapSyncEpoch) {
|
||||
globeShipsEpochRef.current = mapSyncEpoch;
|
||||
}
|
||||
@ -329,8 +323,69 @@ export function useGlobeShips(
|
||||
console.warn('Ship icon image setup failed:', e);
|
||||
}
|
||||
|
||||
// 사전 계산된 GeoJSON 사용 (useMemo에서 projection과 무관하게 항상 준비됨)
|
||||
const geojson = globeShipGeoJson;
|
||||
const globeShipData = shipData;
|
||||
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 {
|
||||
const existing = map.getSource(srcId) as GeoJSONSource | undefined;
|
||||
@ -341,6 +396,7 @@ export function useGlobeShips(
|
||||
return;
|
||||
}
|
||||
|
||||
const visibility = settings.showShips ? 'visible' : 'none';
|
||||
const before = undefined;
|
||||
|
||||
if (!map.getLayer(haloId)) {
|
||||
@ -378,8 +434,35 @@ export function useGlobeShips(
|
||||
} catch (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)) {
|
||||
try {
|
||||
@ -425,8 +508,36 @@ export function useGlobeShips(
|
||||
} catch (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)) {
|
||||
try {
|
||||
@ -487,9 +598,31 @@ export function useGlobeShips(
|
||||
} catch (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 = [
|
||||
'all',
|
||||
['!=', ['to-string', ['coalesce', ['get', 'labelName'], '']], ''],
|
||||
@ -539,14 +672,17 @@ export function useGlobeShips(
|
||||
} catch (e) {
|
||||
console.warn('Ship label layer add failed:', e);
|
||||
}
|
||||
} else {
|
||||
try {
|
||||
map.setLayoutProperty(labelId, 'visibility', labelVisibility);
|
||||
map.setFilter(labelId, labelFilter as never);
|
||||
map.setLayoutProperty(labelId, 'text-field', ['get', 'labelName'] as never);
|
||||
} catch {
|
||||
// ignore
|
||||
}
|
||||
}
|
||||
// label: filter/text-field are static — visibility handled by fast toggle
|
||||
|
||||
// 레이어가 준비되었음을 알림 (projection과 무관 — 토글 버튼 활성화용)
|
||||
onGlobeShipsReady?.(true);
|
||||
if (projection === 'globe') {
|
||||
reorderGlobeFeatureLayers();
|
||||
}
|
||||
reorderGlobeFeatureLayers();
|
||||
kickRepaint(map);
|
||||
};
|
||||
|
||||
@ -558,12 +694,12 @@ export function useGlobeShips(
|
||||
projection,
|
||||
settings.showShips,
|
||||
overlays.shipLabels,
|
||||
globeShipGeoJson,
|
||||
shipData,
|
||||
legacyHits,
|
||||
selectedMmsi,
|
||||
isBaseHighlightedMmsi,
|
||||
mapSyncEpoch,
|
||||
reorderGlobeFeatureLayers,
|
||||
onGlobeShipsReady,
|
||||
]);
|
||||
|
||||
// Globe hover overlay ships
|
||||
@ -577,10 +713,22 @@ export function useGlobeShips(
|
||||
const outlineId = 'ships-globe-hover-outline';
|
||||
const symbolId = 'ships-globe-hover';
|
||||
|
||||
const hideHover = () => {
|
||||
const remove = () => {
|
||||
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 = () => {
|
||||
@ -588,7 +736,7 @@ export function useGlobeShips(
|
||||
if (!map.isStyleLoaded()) return;
|
||||
|
||||
if (projection !== 'globe' || !settings.showShips || shipHoverOverlaySet.size === 0) {
|
||||
hideHover();
|
||||
remove();
|
||||
return;
|
||||
}
|
||||
|
||||
@ -603,7 +751,7 @@ export function useGlobeShips(
|
||||
|
||||
const hovered = shipLayerData.filter((t) => shipHoverOverlaySet.has(t.mmsi) && !!legacyHits?.has(t.mmsi));
|
||||
if (hovered.length === 0) {
|
||||
hideHover();
|
||||
remove();
|
||||
return;
|
||||
}
|
||||
const hoverSignature = hovered
|
||||
|
||||
@ -2,11 +2,12 @@ import { useCallback, useEffect, useRef, type MutableRefObject, type Dispatch, t
|
||||
import maplibregl, { type StyleSpecification } from 'maplibre-gl';
|
||||
import { MapboxOverlay } from '@deck.gl/mapbox';
|
||||
import { MaplibreDeckCustomLayer } from '../MaplibreDeckCustomLayer';
|
||||
import type { BaseMapId, MapProjectionId, MapViewState } from '../types';
|
||||
import { DECK_VIEW_ID, ANCHORED_SHIP_ICON_ID } from '../constants';
|
||||
import type { BaseMapId, MapProjectionId } from '../types';
|
||||
import { DECK_VIEW_ID } from '../constants';
|
||||
import { kickRepaint, onMapStyleReady } from '../lib/mapCore';
|
||||
import { ensureSeamarkOverlay } from '../layers/seamark';
|
||||
import { resolveMapStyle } from '../layers/bathymetry';
|
||||
import { clearGlobeNativeLayers } from '../lib/layerHelpers';
|
||||
|
||||
export function useMapInit(
|
||||
containerRef: MutableRefObject<HTMLDivElement | null>,
|
||||
@ -22,14 +23,10 @@ export function useMapInit(
|
||||
showSeamark: boolean;
|
||||
onViewBboxChange?: (bbox: [number, number, number, number]) => void;
|
||||
setMapSyncEpoch: Dispatch<SetStateAction<number>>;
|
||||
initialView?: MapViewState | null;
|
||||
onViewStateChange?: (view: MapViewState) => void;
|
||||
},
|
||||
) {
|
||||
const { onViewBboxChange, setMapSyncEpoch, showSeamark } = opts;
|
||||
const showSeamarkRef = useRef(showSeamark);
|
||||
const onViewStateChangeRef = useRef(opts.onViewStateChange);
|
||||
useEffect(() => { onViewStateChangeRef.current = opts.onViewStateChange; }, [opts.onViewStateChange]);
|
||||
useEffect(() => {
|
||||
showSeamarkRef.current = showSeamark;
|
||||
}, [showSeamark]);
|
||||
@ -49,6 +46,12 @@ export function useMapInit(
|
||||
}
|
||||
}, []);
|
||||
|
||||
const clearGlobeNativeLayersCb = useCallback(() => {
|
||||
const map = mapRef.current;
|
||||
if (!map) return;
|
||||
clearGlobeNativeLayers(map);
|
||||
}, []);
|
||||
|
||||
const pulseMapSync = useCallback(() => {
|
||||
setMapSyncEpoch((prev) => prev + 1);
|
||||
requestAnimationFrame(() => {
|
||||
@ -62,7 +65,6 @@ export function useMapInit(
|
||||
|
||||
let map: maplibregl.Map | null = null;
|
||||
let cancelled = false;
|
||||
let viewSaveTimer: ReturnType<typeof setInterval> | null = null;
|
||||
const controller = new AbortController();
|
||||
|
||||
(async () => {
|
||||
@ -75,14 +77,13 @@ export function useMapInit(
|
||||
}
|
||||
if (cancelled || !containerRef.current) return;
|
||||
|
||||
const iv = opts.initialView;
|
||||
map = new maplibregl.Map({
|
||||
container: containerRef.current,
|
||||
style,
|
||||
center: iv?.center ?? [126.5, 34.2],
|
||||
zoom: iv?.zoom ?? 7,
|
||||
pitch: iv?.pitch ?? 45,
|
||||
bearing: iv?.bearing ?? 0,
|
||||
center: [126.5, 34.2],
|
||||
zoom: 7,
|
||||
pitch: 45,
|
||||
bearing: 0,
|
||||
maxPitch: 85,
|
||||
dragRotate: 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.ScaleControl({ maxWidth: 120, unit: 'metric' }), 'bottom-left');
|
||||
|
||||
// MapLibre 내부 placement TypeError 방어 + globe easing 경고 억제
|
||||
// symbol layer 추가/제거와 render cycle 간 타이밍 이슈로 발생하는 에러 억제
|
||||
// globe projection에서 scrollZoom이 easeTo(around)를 호출하면 경고 발생 → 구조적 한계로 억제
|
||||
{
|
||||
const origRender = (map as unknown as { _render: (arg?: number) => void })._render;
|
||||
const origWarn = console.warn;
|
||||
(map as unknown as { _render: (arg?: number) => void })._render = function (arg?: number) {
|
||||
// globe 모드에서 scrollZoom의 easeTo around 경고 억제
|
||||
// eslint-disable-next-line no-console
|
||||
console.warn = function (...args: unknown[]) {
|
||||
if (typeof args[0] === 'string') {
|
||||
const msg = args[0] as string;
|
||||
if (msg.includes('Easing around a point')) return;
|
||||
// vertex 경고는 디버그용으로 1회만 출력 후 억제
|
||||
if (msg.includes('Max vertices per segment')) {
|
||||
origWarn.apply(console, args as [unknown, ...unknown[]]);
|
||||
return;
|
||||
}
|
||||
}
|
||||
origWarn.apply(console, args as [unknown, ...unknown[]]);
|
||||
};
|
||||
try {
|
||||
origRender.call(this, arg);
|
||||
} catch (e) {
|
||||
if (e instanceof TypeError && (e.message?.includes("reading 'get'") || e.message?.includes('placement'))) {
|
||||
return;
|
||||
}
|
||||
throw e;
|
||||
} finally {
|
||||
// eslint-disable-next-line no-console
|
||||
console.warn = origWarn;
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
// Globe 모드 전환 시 지연을 제거하기 위해 ship.svg를 미리 로드
|
||||
{
|
||||
const SHIP_IMG_ID = 'ship-globe-icon';
|
||||
const localMap = map;
|
||||
void localMap
|
||||
.loadImage('/assets/ship.svg')
|
||||
.then((response) => {
|
||||
if (cancelled || !localMap) return;
|
||||
const img = (response as { data?: HTMLImageElement | ImageBitmap } | undefined)?.data;
|
||||
if (!img) return;
|
||||
try {
|
||||
if (!localMap.hasImage(SHIP_IMG_ID)) localMap.addImage(SHIP_IMG_ID, img, { pixelRatio: 2, sdf: true });
|
||||
if (!localMap.hasImage(ANCHORED_SHIP_ICON_ID)) localMap.addImage(ANCHORED_SHIP_ICON_ID, img, { pixelRatio: 2, sdf: true });
|
||||
} catch {
|
||||
// ignore — fallback canvas icon이 useGlobeShips에서 사용됨
|
||||
}
|
||||
})
|
||||
.catch(() => {
|
||||
// ignore — useGlobeShips에서 fallback 처리
|
||||
});
|
||||
}
|
||||
|
||||
mapRef.current = map;
|
||||
|
||||
// 양쪽 overlay를 모두 초기화 — projection 전환 시 재생성 비용 제거
|
||||
ensureMercatorOverlay();
|
||||
globeDeckLayerRef.current = new MaplibreDeckCustomLayer({
|
||||
id: 'deck-globe',
|
||||
viewId: DECK_VIEW_ID,
|
||||
deckProps: { layers: [] },
|
||||
});
|
||||
if (projectionRef.current === 'mercator') {
|
||||
const overlay = ensureMercatorOverlay();
|
||||
if (!overlay) return;
|
||||
overlayRef.current = overlay;
|
||||
} else {
|
||||
globeDeckLayerRef.current = new MaplibreDeckCustomLayer({
|
||||
id: 'deck-globe',
|
||||
viewId: DECK_VIEW_ID,
|
||||
deckProps: { layers: [] },
|
||||
});
|
||||
}
|
||||
|
||||
function applyProjection() {
|
||||
if (!map) return;
|
||||
@ -174,9 +122,8 @@ export function useMapInit(
|
||||
|
||||
onMapStyleReady(map, () => {
|
||||
applyProjection();
|
||||
// deck-globe를 항상 추가 (projection과 무관)
|
||||
const deckLayer = globeDeckLayerRef.current;
|
||||
if (deckLayer && !map!.getLayer(deckLayer.id)) {
|
||||
if (projectionRef.current === 'globe' && deckLayer && !map!.getLayer(deckLayer.id)) {
|
||||
try {
|
||||
map!.addLayer(deckLayer);
|
||||
} catch {
|
||||
@ -185,7 +132,7 @@ export function useMapInit(
|
||||
}
|
||||
if (!showSeamarkRef.current) return;
|
||||
try {
|
||||
ensureSeamarkOverlay(map!, 'bathymetry-lines-coarse');
|
||||
ensureSeamarkOverlay(map!, 'bathymetry-lines');
|
||||
} catch {
|
||||
// ignore
|
||||
}
|
||||
@ -200,38 +147,10 @@ export function useMapInit(
|
||||
map.on('load', emitBbox);
|
||||
map.on('moveend', emitBbox);
|
||||
|
||||
// 60초 인터벌로 뷰 상태 저장 (mercator일 때만)
|
||||
viewSaveTimer = setInterval(() => {
|
||||
const cb = onViewStateChangeRef.current;
|
||||
if (!cb || !map || projectionRef.current !== 'mercator') return;
|
||||
const c = map.getCenter();
|
||||
cb({ center: [c.lng, c.lat], zoom: map.getZoom(), bearing: map.getBearing(), pitch: map.getPitch() });
|
||||
}, 60_000);
|
||||
|
||||
map.once('load', () => {
|
||||
// Globe 배경(타일 밖)을 심해 색상과 맞춰 타일 경계 seam을 비가시화
|
||||
try {
|
||||
map!.setSky({
|
||||
'sky-color': '#010610',
|
||||
'horizon-color': '#010610',
|
||||
'fog-color': '#010610',
|
||||
'fog-ground-blend': 1,
|
||||
'sky-horizon-blend': 0,
|
||||
'atmosphere-blend': 0,
|
||||
});
|
||||
} catch {
|
||||
// ignore
|
||||
}
|
||||
// 캔버스 배경도 심해색으로 통일
|
||||
try {
|
||||
map!.getCanvas().style.background = '#010610';
|
||||
} catch {
|
||||
// ignore
|
||||
}
|
||||
|
||||
if (showSeamarkRef.current) {
|
||||
try {
|
||||
ensureSeamarkOverlay(map!, 'bathymetry-lines-coarse');
|
||||
ensureSeamarkOverlay(map!, 'bathymetry-lines');
|
||||
} catch {
|
||||
// ignore
|
||||
}
|
||||
@ -242,22 +161,12 @@ export function useMapInit(
|
||||
// ignore
|
||||
}
|
||||
}
|
||||
// 종속 hook들(useMapStyleSettings 등)이 저장된 설정을 적용하도록 트리거
|
||||
setMapSyncEpoch((prev) => prev + 1);
|
||||
});
|
||||
})();
|
||||
|
||||
return () => {
|
||||
cancelled = true;
|
||||
controller.abort();
|
||||
if (viewSaveTimer) clearInterval(viewSaveTimer);
|
||||
|
||||
// 최종 뷰 상태 저장 (mercator일 때만 — globe 위치는 영속화하지 않음)
|
||||
const cb = onViewStateChangeRef.current;
|
||||
if (cb && map && projectionRef.current === 'mercator') {
|
||||
const c = map.getCenter();
|
||||
cb({ center: [c.lng, c.lat], zoom: map.getZoom(), bearing: map.getBearing(), pitch: map.getPitch() });
|
||||
}
|
||||
|
||||
try {
|
||||
globeDeckLayerRef.current?.requestFinalize();
|
||||
@ -283,5 +192,5 @@ export function useMapInit(
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, []);
|
||||
|
||||
return { ensureMercatorOverlay, pulseMapSync };
|
||||
return { ensureMercatorOverlay, clearGlobeNativeLayers: clearGlobeNativeLayersCb, pulseMapSync };
|
||||
}
|
||||
|
||||
@ -35,7 +35,7 @@ function applyLabelLanguage(map: maplibregl.Map, lang: MapLabelLanguage) {
|
||||
if (layer.type !== 'symbol') continue;
|
||||
const layout = (layer as { layout?: Record<string, unknown> }).layout;
|
||||
if (!layout?.['text-field']) continue;
|
||||
if (layer.id.startsWith('bathymetry-labels')) continue;
|
||||
if (layer.id === 'bathymetry-labels') continue;
|
||||
const textField =
|
||||
lang === 'local'
|
||||
? ['get', 'name']
|
||||
@ -63,7 +63,6 @@ function applyLandColor(map: maplibregl.Map, color: string) {
|
||||
if (id.startsWith('fc-')) continue;
|
||||
if (id.startsWith('fleet-')) continue;
|
||||
if (id.startsWith('predict-')) continue;
|
||||
if (id.startsWith('vessel-track-')) continue;
|
||||
if (id === 'deck-globe') continue;
|
||||
const sourceLayer = String((layer as Record<string, unknown>)['source-layer'] ?? '');
|
||||
const isWater = waterRegex.test(id) || waterRegex.test(sourceLayer);
|
||||
@ -103,19 +102,17 @@ function applyWaterBaseColor(map: maplibregl.Map, fillColor: string) {
|
||||
}
|
||||
|
||||
function applyDepthGradient(map: maplibregl.Map, stops: DepthColorStop[]) {
|
||||
if (!map.getLayer('bathymetry-fill')) return;
|
||||
const depth = ['to-number', ['get', 'depth']];
|
||||
const sorted = [...stops].sort((a, b) => a.depth - b.depth);
|
||||
if (sorted.length === 0) return;
|
||||
const expr: unknown[] = ['interpolate', ['linear'], depth];
|
||||
const deepest = sorted[0];
|
||||
if (deepest) expr.push(-11000, darkenHex(deepest.color, 0.5));
|
||||
for (const s of sorted) {
|
||||
expr.push(s.depth, s.color);
|
||||
}
|
||||
// 0m까지 확장 (최천층 stop이 0보다 깊으면)
|
||||
const shallowest = sorted[sorted.length - 1];
|
||||
if (shallowest.depth < 0) {
|
||||
expr.push(0, lightenHex(shallowest.color, 1.8));
|
||||
}
|
||||
if (!map.getLayer('bathymetry-fill')) return;
|
||||
if (shallowest) expr.push(0, lightenHex(shallowest.color, 1.8));
|
||||
try {
|
||||
map.setPaintProperty('bathymetry-fill', 'fill-color', expr as never);
|
||||
} catch {
|
||||
@ -125,7 +122,7 @@ function applyDepthGradient(map: maplibregl.Map, stops: DepthColorStop[]) {
|
||||
|
||||
function applyDepthFontSize(map: maplibregl.Map, size: DepthFontSize) {
|
||||
const expr = DEPTH_FONT_SIZE_MAP[size];
|
||||
for (const layerId of ['bathymetry-labels', 'bathymetry-labels-coarse', 'bathymetry-landforms']) {
|
||||
for (const layerId of ['bathymetry-labels', 'bathymetry-landforms']) {
|
||||
if (!map.getLayer(layerId)) continue;
|
||||
try {
|
||||
map.setLayoutProperty(layerId, 'text-size', expr);
|
||||
@ -136,7 +133,7 @@ function applyDepthFontSize(map: maplibregl.Map, size: DepthFontSize) {
|
||||
}
|
||||
|
||||
function applyDepthFontColor(map: maplibregl.Map, color: string) {
|
||||
for (const layerId of ['bathymetry-labels', 'bathymetry-labels-coarse', 'bathymetry-landforms']) {
|
||||
for (const layerId of ['bathymetry-labels', 'bathymetry-landforms']) {
|
||||
if (!map.getLayer(layerId)) continue;
|
||||
try {
|
||||
map.setPaintProperty(layerId, 'text-color', color);
|
||||
|
||||
@ -3,7 +3,9 @@ import type maplibregl from 'maplibre-gl';
|
||||
import { MapboxOverlay } from '@deck.gl/mapbox';
|
||||
import { MaplibreDeckCustomLayer } from '../MaplibreDeckCustomLayer';
|
||||
import type { MapProjectionId } from '../types';
|
||||
import { DECK_VIEW_ID } from '../constants';
|
||||
import { kickRepaint, onMapStyleReady, extractProjectionType } from '../lib/mapCore';
|
||||
import { removeLayerIfExists } from '../lib/layerHelpers';
|
||||
|
||||
export function useProjectionToggle(
|
||||
mapRef: MutableRefObject<maplibregl.Map | null>,
|
||||
@ -13,13 +15,14 @@ export function useProjectionToggle(
|
||||
projectionBusyRef: MutableRefObject<boolean>,
|
||||
opts: {
|
||||
projection: MapProjectionId;
|
||||
clearGlobeNativeLayers: () => void;
|
||||
ensureMercatorOverlay: () => MapboxOverlay | null;
|
||||
onProjectionLoadingChange?: (loading: boolean) => void;
|
||||
pulseMapSync: () => void;
|
||||
setMapSyncEpoch: (updater: (prev: number) => number) => void;
|
||||
},
|
||||
): () => void {
|
||||
const { projection, ensureMercatorOverlay, onProjectionLoadingChange, pulseMapSync, setMapSyncEpoch } = opts;
|
||||
const { projection, clearGlobeNativeLayers, ensureMercatorOverlay, onProjectionLoadingChange, pulseMapSync, setMapSyncEpoch } = opts;
|
||||
|
||||
const projectionBusyTokenRef = useRef(0);
|
||||
const projectionBusyTimerRef = useRef<ReturnType<typeof window.setTimeout> | null>(null);
|
||||
@ -68,7 +71,7 @@ export function useProjectionToggle(
|
||||
if (!projectionBusyRef.current || projectionBusyTokenRef.current !== token) return;
|
||||
console.debug('Projection loading fallback timeout reached.');
|
||||
endProjectionLoading();
|
||||
}, 2000);
|
||||
}, 4000);
|
||||
},
|
||||
[clearProjectionBusyTimer, endProjectionLoading, onProjectionLoadingChange],
|
||||
);
|
||||
@ -94,11 +97,6 @@ export function useProjectionToggle(
|
||||
'subcables-glow',
|
||||
'subcables-points',
|
||||
'subcables-label',
|
||||
'vessel-track-line',
|
||||
'vessel-track-line-hitarea',
|
||||
'vessel-track-arrow',
|
||||
'vessel-track-pts',
|
||||
'vessel-track-pts-highlight',
|
||||
'zones-fill',
|
||||
'zones-line',
|
||||
'zones-label',
|
||||
@ -116,6 +114,7 @@ export function useProjectionToggle(
|
||||
'pair-lines-ml',
|
||||
'fc-lines-ml',
|
||||
'pair-range-ml',
|
||||
'fleet-circles-ml-fill',
|
||||
'fleet-circles-ml',
|
||||
];
|
||||
|
||||
@ -177,14 +176,45 @@ export function useProjectionToggle(
|
||||
|
||||
if (isTransition) setProjectionLoading(true);
|
||||
|
||||
// 파괴하지 않고 레이어만 비움 — 양쪽 파이프라인 항상 유지
|
||||
const quietMercatorOverlays = () => {
|
||||
try { overlayRef.current?.setProps({ layers: [] } as never); } catch { /* ignore */ }
|
||||
try { overlayInteractionRef.current?.setProps({ layers: [] } as never); } catch { /* ignore */ }
|
||||
const disposeMercatorOverlays = () => {
|
||||
const disposeOne = (target: MapboxOverlay | null, toNull: 'base' | 'interaction') => {
|
||||
if (!target) return;
|
||||
try {
|
||||
target.setProps({ layers: [] } as never);
|
||||
} catch {
|
||||
// ignore
|
||||
}
|
||||
try {
|
||||
map.removeControl(target as never);
|
||||
} catch {
|
||||
// ignore
|
||||
}
|
||||
try {
|
||||
target.finalize();
|
||||
} catch {
|
||||
// ignore
|
||||
}
|
||||
if (toNull === 'base') {
|
||||
overlayRef.current = null;
|
||||
} else {
|
||||
overlayInteractionRef.current = null;
|
||||
}
|
||||
};
|
||||
|
||||
disposeOne(overlayRef.current, 'base');
|
||||
disposeOne(overlayInteractionRef.current, 'interaction');
|
||||
};
|
||||
|
||||
const quietGlobeDeckLayer = () => {
|
||||
try { globeDeckLayerRef.current?.setProps({ layers: [] } as never); } catch { /* ignore */ }
|
||||
const disposeGlobeDeckLayer = () => {
|
||||
const current = globeDeckLayerRef.current;
|
||||
if (!current) return;
|
||||
removeLayerIfExists(map, current.id);
|
||||
try {
|
||||
current.requestFinalize();
|
||||
} catch {
|
||||
// ignore
|
||||
}
|
||||
globeDeckLayerRef.current = null;
|
||||
};
|
||||
|
||||
const syncProjectionAndDeck = () => {
|
||||
@ -206,9 +236,11 @@ export function useProjectionToggle(
|
||||
const shouldSwitchProjection = currentProjection !== next;
|
||||
|
||||
if (projection === 'globe') {
|
||||
quietMercatorOverlays();
|
||||
disposeMercatorOverlays();
|
||||
clearGlobeNativeLayers();
|
||||
} else {
|
||||
quietGlobeDeckLayer();
|
||||
disposeGlobeDeckLayer();
|
||||
clearGlobeNativeLayers();
|
||||
}
|
||||
|
||||
try {
|
||||
@ -216,17 +248,6 @@ export function useProjectionToggle(
|
||||
map.setProjection({ type: next });
|
||||
}
|
||||
map.setRenderWorldCopies(next !== 'globe');
|
||||
|
||||
// Globe에서는 easeTo around 미지원 → scrollZoom 동작 전환
|
||||
try {
|
||||
map.scrollZoom.disable();
|
||||
if (next === 'globe') {
|
||||
map.scrollZoom.enable();
|
||||
} else {
|
||||
map.scrollZoom.enable({ around: 'center' });
|
||||
}
|
||||
} catch { /* ignore */ }
|
||||
|
||||
if (shouldSwitchProjection && currentProjection !== next && !cancelled && retries < maxRetries) {
|
||||
retries += 1;
|
||||
window.requestAnimationFrame(() => syncProjectionAndDeck());
|
||||
@ -242,9 +263,17 @@ export function useProjectionToggle(
|
||||
console.warn('Projection switch failed:', e);
|
||||
}
|
||||
|
||||
// 양쪽 overlay가 항상 존재하므로 재생성 불필요
|
||||
// deck-globe가 map에서 빠져있을 경우에만 재추가
|
||||
if (projection === 'globe') {
|
||||
disposeGlobeDeckLayer();
|
||||
|
||||
if (!globeDeckLayerRef.current) {
|
||||
globeDeckLayerRef.current = new MaplibreDeckCustomLayer({
|
||||
id: 'deck-globe',
|
||||
viewId: DECK_VIEW_ID,
|
||||
deckProps: { layers: [] },
|
||||
});
|
||||
}
|
||||
|
||||
const layer = globeDeckLayerRef.current;
|
||||
const layerId = layer?.id;
|
||||
if (layer && layerId && map.isStyleLoaded() && !map.getLayer(layerId)) {
|
||||
@ -253,8 +282,14 @@ export function useProjectionToggle(
|
||||
} catch {
|
||||
// ignore
|
||||
}
|
||||
if (!map.getLayer(layerId) && !cancelled && retries < maxRetries) {
|
||||
retries += 1;
|
||||
window.requestAnimationFrame(() => syncProjectionAndDeck());
|
||||
return;
|
||||
}
|
||||
}
|
||||
} else {
|
||||
disposeGlobeDeckLayer();
|
||||
ensureMercatorOverlay();
|
||||
}
|
||||
|
||||
@ -289,7 +324,7 @@ export function useProjectionToggle(
|
||||
if (settleCleanup) settleCleanup();
|
||||
if (isTransition) setProjectionLoading(false);
|
||||
};
|
||||
}, [projection, ensureMercatorOverlay, reorderGlobeFeatureLayers, setProjectionLoading]);
|
||||
}, [projection, clearGlobeNativeLayers, ensureMercatorOverlay, reorderGlobeFeatureLayers, setProjectionLoading]);
|
||||
|
||||
return reorderGlobeFeatureLayers;
|
||||
}
|
||||
|
||||
@ -16,7 +16,6 @@ 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];
|
||||
@ -64,10 +63,10 @@ const LAYER_SPECS: NativeLayerSpec[] = [
|
||||
type: 'line',
|
||||
sourceId: SRC_ID,
|
||||
paint: {
|
||||
'line-color': '#ffffff',
|
||||
'line-color': ['get', 'color'],
|
||||
'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],
|
||||
'line-width': ['interpolate', ['linear'], ['zoom'], 2, 5, 6, 8, 10, 12],
|
||||
'line-blur': ['interpolate', ['linear'], ['zoom'], 2, 3, 6, 5, 10, 7],
|
||||
},
|
||||
filter: ['==', ['get', 'id'], ''],
|
||||
layout: { 'line-cap': 'round', 'line-join': 'round' },
|
||||
@ -108,29 +107,6 @@ const LAYER_SPECS: NativeLayerSpec[] = [
|
||||
},
|
||||
minzoom: 4,
|
||||
},
|
||||
{
|
||||
id: HOVER_LABEL_ID,
|
||||
type: 'symbol',
|
||||
sourceId: SRC_ID,
|
||||
paint: {
|
||||
'text-color': '#ffffff',
|
||||
'text-halo-color': 'rgba(0,0,0,0.85)',
|
||||
'text-halo-width': 2,
|
||||
'text-halo-blur': 0.5,
|
||||
'text-opacity': 0,
|
||||
},
|
||||
layout: {
|
||||
'symbol-placement': 'line',
|
||||
'text-field': ['get', 'name'],
|
||||
'text-size': ['interpolate', ['linear'], ['zoom'], 2, 14, 6, 17, 10, 20],
|
||||
'text-font': ['Noto Sans Bold', 'Open Sans Bold'],
|
||||
'text-allow-overlap': true,
|
||||
'text-padding': 2,
|
||||
'text-rotation-alignment': 'map',
|
||||
},
|
||||
filter: ['==', ['get', 'id'], ''],
|
||||
minzoom: 2,
|
||||
},
|
||||
];
|
||||
|
||||
export function useSubcablesLayer(
|
||||
@ -274,27 +250,42 @@ export function useSubcablesLayer(
|
||||
}
|
||||
|
||||
/* ── Hover highlight helper (paint-only mutations) ────────────────── */
|
||||
// 기본 레이어는 항상 기본값 유지, glow 레이어(filter 기반)로만 호버 강조
|
||||
function applyHoverHighlight(map: maplibregl.Map, hoveredId: string | null) {
|
||||
const noMatch = ['==', ['get', 'id'], ''] as never;
|
||||
if (hoveredId) {
|
||||
const matchExpr = ['==', ['get', 'id'], hoveredId];
|
||||
|
||||
if (map.getLayer(LINE_ID)) {
|
||||
map.setPaintProperty(LINE_ID, 'line-opacity', ['case', matchExpr, 1.0, 0.2] as never);
|
||||
map.setPaintProperty(LINE_ID, 'line-width', ['case', matchExpr, 4.5, 1.2] as never);
|
||||
}
|
||||
if (map.getLayer(CASING_ID)) {
|
||||
map.setPaintProperty(CASING_ID, 'line-opacity', ['case', matchExpr, 0.7, 0.12] as never);
|
||||
map.setPaintProperty(CASING_ID, 'line-width', ['case', matchExpr, 6.5, 2.0] as never);
|
||||
}
|
||||
if (map.getLayer(GLOW_ID)) {
|
||||
map.setFilter(GLOW_ID, matchExpr as never);
|
||||
map.setPaintProperty(GLOW_ID, 'line-opacity', 0.55);
|
||||
map.setPaintProperty(GLOW_ID, 'line-opacity', 0.35);
|
||||
}
|
||||
if (map.getLayer(HOVER_LABEL_ID)) {
|
||||
map.setFilter(HOVER_LABEL_ID, matchExpr as never);
|
||||
map.setPaintProperty(HOVER_LABEL_ID, 'text-opacity', 1.0);
|
||||
if (map.getLayer(POINTS_ID)) {
|
||||
map.setPaintProperty(POINTS_ID, 'circle-opacity', ['case', matchExpr, 1.0, 0.15] as never);
|
||||
map.setPaintProperty(POINTS_ID, 'circle-radius', ['case', matchExpr, 4, 1.5] as never);
|
||||
}
|
||||
} else {
|
||||
if (map.getLayer(LINE_ID)) {
|
||||
map.setPaintProperty(LINE_ID, 'line-opacity', LINE_OPACITY_DEFAULT as never);
|
||||
map.setPaintProperty(LINE_ID, 'line-width', LINE_WIDTH_DEFAULT as never);
|
||||
}
|
||||
if (map.getLayer(CASING_ID)) {
|
||||
map.setPaintProperty(CASING_ID, 'line-opacity', CASING_OPACITY_DEFAULT as never);
|
||||
map.setPaintProperty(CASING_ID, 'line-width', CASING_WIDTH_DEFAULT as never);
|
||||
}
|
||||
if (map.getLayer(GLOW_ID)) {
|
||||
map.setFilter(GLOW_ID, noMatch);
|
||||
map.setFilter(GLOW_ID, ['==', ['get', 'id'], ''] as never);
|
||||
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);
|
||||
if (map.getLayer(POINTS_ID)) {
|
||||
map.setPaintProperty(POINTS_ID, 'circle-opacity', POINTS_OPACITY_DEFAULT as never);
|
||||
map.setPaintProperty(POINTS_ID, 'circle-radius', POINTS_RADIUS_DEFAULT as never);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@ -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, {
|
||||
type GeoJSONSource,
|
||||
type GeoJSONSourceSpecification,
|
||||
@ -10,34 +10,6 @@ import type { ZonesGeoJson } from '../../../entities/zone/api/useZones';
|
||||
import type { MapToggleState } from '../../../features/mapToggles/MapToggles';
|
||||
import type { BaseMapId, MapProjectionId } from '../types';
|
||||
import { kickRepaint, onMapStyleReady } from '../lib/mapCore';
|
||||
import { guardedSetVisibility } from '../lib/layerHelpers';
|
||||
|
||||
/** Globe tessellation에서 vertex 65535 초과를 방지하기 위해 좌표 수를 줄임.
|
||||
* 수역 폴리곤(해안선 디테일 2100+ vertex)이 globe에서 70,000+로 폭증하므로
|
||||
* ring당 최대 maxPts개로 서브샘플링. 라벨 centroid에는 영향 없음. */
|
||||
function simplifyZonesForGlobe(zones: ZonesGeoJson): ZonesGeoJson {
|
||||
const MAX_PTS = 60;
|
||||
const subsample = (ring: GeoJSON.Position[]): GeoJSON.Position[] => {
|
||||
if (ring.length <= MAX_PTS) return ring;
|
||||
const step = Math.ceil(ring.length / MAX_PTS);
|
||||
const out: GeoJSON.Position[] = [ring[0]];
|
||||
for (let i = step; i < ring.length - 1; i += step) out.push(ring[i]);
|
||||
out.push(ring[0]); // close ring
|
||||
return out;
|
||||
};
|
||||
return {
|
||||
...zones,
|
||||
features: zones.features.map((f) => ({
|
||||
...f,
|
||||
geometry: {
|
||||
...f.geometry,
|
||||
coordinates: f.geometry.coordinates.map((polygon) =>
|
||||
polygon.map((ring) => subsample(ring)),
|
||||
),
|
||||
},
|
||||
})),
|
||||
};
|
||||
}
|
||||
|
||||
export function useZonesLayer(
|
||||
mapRef: MutableRefObject<maplibregl.Map | null>,
|
||||
@ -54,12 +26,6 @@ export function useZonesLayer(
|
||||
) {
|
||||
const { zones, overlays, projection, baseMap, hoveredZoneId, mapSyncEpoch } = opts;
|
||||
|
||||
// globe용 간소화 데이터를 미리 캐싱 — ensure() 내 매번 재계산 방지
|
||||
const simplifiedZones = useMemo(
|
||||
() => (zones ? simplifyZonesForGlobe(zones) : null),
|
||||
[zones],
|
||||
);
|
||||
|
||||
useEffect(() => {
|
||||
const map = mapRef.current;
|
||||
if (!map) return;
|
||||
@ -81,31 +47,33 @@ export function useZonesLayer(
|
||||
zoneLabelExpr.push(['coalesce', ['get', 'zoneName'], ['get', 'zoneLabel'], ['get', 'NAME'], '수역']);
|
||||
|
||||
const ensure = () => {
|
||||
// 소스 데이터 간소화 — projectionBusy 중에도 실행해야 함
|
||||
// globe 전환 시 projectionBusy 가드 뒤에서만 실행하면 MapLibre가
|
||||
// 원본(2100+ vertex) 데이터로 globe tessellation → 73,000+ vertex → 노란 막대
|
||||
const sourceData = projection === 'globe' ? simplifiedZones : zones;
|
||||
if (sourceData) {
|
||||
try {
|
||||
const existing = map.getSource(srcId) as GeoJSONSource | undefined;
|
||||
if (existing) existing.setData(sourceData);
|
||||
} catch { /* ignore — source may not exist yet */ }
|
||||
if (projectionBusyRef.current) return;
|
||||
const visibility = overlays.zones ? 'visible' : 'none';
|
||||
try {
|
||||
if (map.getLayer(fillId)) map.setLayoutProperty(fillId, 'visibility', visibility);
|
||||
} catch {
|
||||
// ignore
|
||||
}
|
||||
try {
|
||||
if (map.getLayer(lineId)) map.setLayoutProperty(lineId, 'visibility', visibility);
|
||||
} catch {
|
||||
// ignore
|
||||
}
|
||||
try {
|
||||
if (map.getLayer(labelId)) map.setLayoutProperty(labelId, 'visibility', visibility);
|
||||
} catch {
|
||||
// ignore
|
||||
}
|
||||
|
||||
const visibility: 'visible' | 'none' = overlays.zones ? 'visible' : 'none';
|
||||
guardedSetVisibility(map, fillId, visibility);
|
||||
guardedSetVisibility(map, lineId, visibility);
|
||||
guardedSetVisibility(map, labelId, visibility);
|
||||
|
||||
if (projectionBusyRef.current) return;
|
||||
if (!zones) return;
|
||||
if (!map.isStyleLoaded()) return;
|
||||
|
||||
try {
|
||||
// 소스가 아직 없으면 생성 (setData는 위에서 이미 처리됨)
|
||||
if (!map.getSource(srcId)) {
|
||||
const data = projection === 'globe' ? simplifiedZones ?? zones : zones;
|
||||
map.addSource(srcId, { type: 'geojson', data: data! } as GeoJSONSourceSpecification);
|
||||
const existing = map.getSource(srcId) as GeoJSONSource | undefined;
|
||||
if (existing) {
|
||||
existing.setData(zones);
|
||||
} else {
|
||||
map.addSource(srcId, { type: 'geojson', data: zones } as GeoJSONSourceSpecification);
|
||||
}
|
||||
|
||||
const style = map.getStyle();
|
||||
@ -258,5 +226,5 @@ export function useZonesLayer(
|
||||
return () => {
|
||||
stop();
|
||||
};
|
||||
}, [zones, simplifiedZones, overlays.zones, projection, baseMap, hoveredZoneId, mapSyncEpoch, reorderGlobeFeatureLayers]);
|
||||
}, [zones, overlays.zones, projection, baseMap, hoveredZoneId, mapSyncEpoch, reorderGlobeFeatureLayers]);
|
||||
}
|
||||
|
||||
@ -11,53 +11,10 @@ export const SHALLOW_WATER_LINE_DEFAULT = '#114f5c';
|
||||
|
||||
const BATHY_ZOOM_RANGES: BathyZoomRange[] = [
|
||||
{ id: 'bathymetry-fill', mercator: [3, 24], globe: [3, 24] },
|
||||
{ id: 'bathymetry-borders-major', mercator: [3, 7], globe: [3, 7] },
|
||||
{ id: 'bathymetry-borders', mercator: [7, 24], globe: [7, 24] },
|
||||
{ id: 'bathymetry-lines-coarse', mercator: [5, 7], globe: [5, 7] },
|
||||
{ id: 'bathymetry-lines-major', mercator: [7, 9], globe: [7, 9] },
|
||||
{ id: 'bathymetry-lines-detail', mercator: [9, 24], globe: [9, 24] },
|
||||
{ id: 'bathymetry-labels-coarse', mercator: [6, 9], globe: [6, 9] },
|
||||
{ id: 'bathymetry-labels', mercator: [9, 24], globe: [9, 24] },
|
||||
{ id: 'bathymetry-borders', mercator: [5, 24], globe: [5, 24] },
|
||||
{ id: 'bathymetry-borders-major', mercator: [3, 24], globe: [3, 24] },
|
||||
];
|
||||
|
||||
/**
|
||||
* 줌 기반 LOD — 줌아웃 시 vertex가 폭증하는 육지 레이어의 minzoom을 올려
|
||||
* 광역 뷰에서는 생략하고, 줌인 시 자연스럽게 디테일이 나타나도록 함.
|
||||
* 해양 서비스 특성상 육지 디테일은 연안 확대 시에만 필요.
|
||||
*/
|
||||
function applyLandLayerLOD(style: StyleSpecification): void {
|
||||
if (!style.layers || !Array.isArray(style.layers)) return;
|
||||
|
||||
// source-layer → 렌더링을 시작할 최소 줌 레벨
|
||||
// globe 모드 줌아웃 시 vertex 65535 초과로 GPU 렌더링 아티팩트(노란 막대) 방지
|
||||
const LOD_MINZOOM: Record<string, number> = {
|
||||
'landcover': 9,
|
||||
'globallandcover': 9,
|
||||
'landuse': 11,
|
||||
'boundary': 5,
|
||||
'transportation': 8,
|
||||
'transportation_name': 10,
|
||||
'building': 14,
|
||||
'housenumber': 16,
|
||||
'aeroway': 11,
|
||||
'park': 10,
|
||||
'mountain_peak': 11,
|
||||
};
|
||||
|
||||
for (const layer of style.layers as unknown as LayerSpecification[]) {
|
||||
const spec = layer as Record<string, unknown>;
|
||||
const sourceLayer = spec['source-layer'] as string | undefined;
|
||||
if (!sourceLayer) continue;
|
||||
const lodMin = LOD_MINZOOM[sourceLayer];
|
||||
if (lodMin === undefined) continue;
|
||||
// 기존 minzoom보다 높을 때만 덮어씀
|
||||
const current = (spec.minzoom as number) ?? 0;
|
||||
if (lodMin > current) {
|
||||
spec.minzoom = lodMin;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
export function injectOceanBathymetryLayers(style: StyleSpecification, maptilerKey: string) {
|
||||
const oceanSourceId = 'maptiler-ocean';
|
||||
|
||||
@ -74,13 +31,17 @@ export function injectOceanBathymetryLayers(style: StyleSpecification, maptilerK
|
||||
const depth = ['to-number', ['get', 'depth']] as unknown as number[];
|
||||
const depthLabel = ['concat', ['to-string', ['*', depth, -1]], 'm'] as unknown as string[];
|
||||
|
||||
// 수심 색상: 전체 범위 (-8000m ~ 0m)
|
||||
// Bug #3 fix: shallow depths now use brighter teal tones to distinguish from deep ocean
|
||||
const bathyFillColor = [
|
||||
'interpolate',
|
||||
['linear'],
|
||||
depth,
|
||||
-11000,
|
||||
'#00040b',
|
||||
-8000,
|
||||
'#010610',
|
||||
-6000,
|
||||
'#020816',
|
||||
-4000,
|
||||
'#030c1c',
|
||||
-2000,
|
||||
@ -103,15 +64,6 @@ export function injectOceanBathymetryLayers(style: StyleSpecification, maptilerK
|
||||
'#2097a6',
|
||||
] as const;
|
||||
|
||||
// --- Depth tiers for zoom-based LOD ---
|
||||
// 줌 기반 LOD로 vertex 제어 — 줌아웃에선 주요 등심선만, 줌인에서 점진적 디테일
|
||||
const DEPTHS_COARSE = [-1000, -2000];
|
||||
const DEPTHS_MEDIUM = [-100, -500, -1000, -2000, -4000];
|
||||
const DEPTHS_DETAIL = [-50, -100, -200, -500, -1000, -2000, -4000, -8000];
|
||||
const depthIn = (depths: number[]) =>
|
||||
['in', depth, ['literal', depths]] as unknown[];
|
||||
|
||||
// === Fill (contour polygons) — 단일 레이어, 전체 depth ===
|
||||
const bathyFill: LayerSpecification = {
|
||||
id: 'bathymetry-fill',
|
||||
type: 'fill',
|
||||
@ -125,132 +77,98 @@ export function injectOceanBathymetryLayers(style: StyleSpecification, maptilerK
|
||||
},
|
||||
} as unknown as LayerSpecification;
|
||||
|
||||
// === Borders (contour polygon edges) — 2-tier LOD ===
|
||||
// z3-z7: 1000m, 2000m 경계만
|
||||
const bathyBordersMajor: LayerSpecification = {
|
||||
id: 'bathymetry-borders-major',
|
||||
type: 'line',
|
||||
source: oceanSourceId,
|
||||
'source-layer': 'contour',
|
||||
minzoom: 3,
|
||||
maxzoom: 7,
|
||||
filter: depthIn(DEPTHS_COARSE) as unknown as unknown[],
|
||||
paint: {
|
||||
'line-color': 'rgba(255,255,255,0.14)',
|
||||
'line-opacity': ['interpolate', ['linear'], ['zoom'], 3, 0.10, 6, 0.16],
|
||||
'line-blur': ['interpolate', ['linear'], ['zoom'], 3, 0.5, 6, 0.35],
|
||||
'line-width': ['interpolate', ['linear'], ['zoom'], 3, 0.25, 6, 0.4],
|
||||
},
|
||||
} as unknown as LayerSpecification;
|
||||
|
||||
// z7+: 전체 등심선 경계
|
||||
const bathyBorders: LayerSpecification = {
|
||||
const bathyBandBorders: LayerSpecification = {
|
||||
id: 'bathymetry-borders',
|
||||
type: 'line',
|
||||
source: oceanSourceId,
|
||||
'source-layer': 'contour',
|
||||
minzoom: 7,
|
||||
minzoom: 5, // fill은 3부터, borders는 5부터
|
||||
maxzoom: 24,
|
||||
paint: {
|
||||
'line-color': 'rgba(255,255,255,0.06)',
|
||||
'line-opacity': ['interpolate', ['linear'], ['zoom'], 7, 0.18, 12, 0.22],
|
||||
'line-blur': ['interpolate', ['linear'], ['zoom'], 7, 0.3, 10, 0.2],
|
||||
'line-width': ['interpolate', ['linear'], ['zoom'], 7, 0.35, 12, 0.6],
|
||||
'line-opacity': ['interpolate', ['linear'], ['zoom'], 4, 0.12, 8, 0.18, 12, 0.22],
|
||||
'line-blur': ['interpolate', ['linear'], ['zoom'], 4, 0.8, 10, 0.2],
|
||||
'line-width': ['interpolate', ['linear'], ['zoom'], 4, 0.2, 8, 0.35, 12, 0.6],
|
||||
},
|
||||
} as unknown as LayerSpecification;
|
||||
|
||||
// === Contour lines (contour_line) — 3-tier LOD ===
|
||||
// z5-z7: 1000m, 2000m만
|
||||
const bathyLinesCoarse: LayerSpecification = {
|
||||
id: 'bathymetry-lines-coarse',
|
||||
const bathyLinesMinor: LayerSpecification = {
|
||||
id: 'bathymetry-lines',
|
||||
type: 'line',
|
||||
source: oceanSourceId,
|
||||
'source-layer': 'contour_line',
|
||||
minzoom: 5,
|
||||
maxzoom: 7,
|
||||
filter: depthIn(DEPTHS_COARSE) as unknown as unknown[],
|
||||
minzoom: 7,
|
||||
paint: {
|
||||
'line-color': 'rgba(255,255,255,0.12)',
|
||||
'line-opacity': ['interpolate', ['linear'], ['zoom'], 5, 0.15, 7, 0.22],
|
||||
'line-blur': 0.5,
|
||||
'line-width': ['interpolate', ['linear'], ['zoom'], 5, 0.4, 7, 0.6],
|
||||
'line-color': [
|
||||
'interpolate',
|
||||
['linear'],
|
||||
depth,
|
||||
-11000,
|
||||
'rgba(255,255,255,0.04)',
|
||||
-6000,
|
||||
'rgba(255,255,255,0.05)',
|
||||
-2000,
|
||||
'rgba(255,255,255,0.07)',
|
||||
0,
|
||||
'rgba(255,255,255,0.10)',
|
||||
],
|
||||
'line-opacity': ['interpolate', ['linear'], ['zoom'], 8, 0.18, 10, 0.22, 12, 0.28],
|
||||
'line-blur': ['interpolate', ['linear'], ['zoom'], 8, 0.8, 11, 0.3],
|
||||
'line-width': ['interpolate', ['linear'], ['zoom'], 8, 0.35, 10, 0.55, 12, 0.85],
|
||||
},
|
||||
} 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 = {
|
||||
id: 'bathymetry-lines-major',
|
||||
type: 'line',
|
||||
source: oceanSourceId,
|
||||
'source-layer': 'contour_line',
|
||||
minzoom: 7,
|
||||
maxzoom: 9,
|
||||
filter: depthIn(DEPTHS_MEDIUM) as unknown as unknown[],
|
||||
maxzoom: 24,
|
||||
filter: bathyMajorDepthFilter as unknown as unknown[],
|
||||
paint: {
|
||||
'line-color': 'rgba(255,255,255,0.16)',
|
||||
'line-opacity': ['interpolate', ['linear'], ['zoom'], 7, 0.22, 9, 0.28],
|
||||
'line-blur': ['interpolate', ['linear'], ['zoom'], 7, 0.4, 9, 0.2],
|
||||
'line-width': ['interpolate', ['linear'], ['zoom'], 7, 0.6, 9, 0.95],
|
||||
'line-opacity': ['interpolate', ['linear'], ['zoom'], 8, 0.22, 10, 0.28, 12, 0.34],
|
||||
'line-blur': ['interpolate', ['linear'], ['zoom'], 8, 0.4, 11, 0.2],
|
||||
'line-width': ['interpolate', ['linear'], ['zoom'], 8, 0.6, 10, 0.95, 12, 1.3],
|
||||
},
|
||||
} as unknown as LayerSpecification;
|
||||
|
||||
// z9+: 50~8000m (풀 디테일)
|
||||
const bathyLinesDetail: LayerSpecification = {
|
||||
id: 'bathymetry-lines-detail',
|
||||
const bathyBandBordersMajor: LayerSpecification = {
|
||||
id: 'bathymetry-borders-major',
|
||||
type: 'line',
|
||||
source: oceanSourceId,
|
||||
'source-layer': 'contour_line',
|
||||
minzoom: 9,
|
||||
'source-layer': 'contour',
|
||||
minzoom: 3,
|
||||
maxzoom: 24,
|
||||
filter: depthIn(DEPTHS_DETAIL) as unknown as unknown[],
|
||||
filter: bathyMajorDepthFilter as unknown as unknown[],
|
||||
paint: {
|
||||
'line-color': 'rgba(255,255,255,0.16)',
|
||||
'line-opacity': ['interpolate', ['linear'], ['zoom'], 9, 0.28, 12, 0.34],
|
||||
'line-blur': ['interpolate', ['linear'], ['zoom'], 9, 0.2, 11, 0.15],
|
||||
'line-width': ['interpolate', ['linear'], ['zoom'], 9, 0.95, 12, 1.3],
|
||||
'line-color': 'rgba(255,255,255,0.14)',
|
||||
'line-opacity': ['interpolate', ['linear'], ['zoom'], 3, 0.10, 6, 0.16, 8, 0.2, 12, 0.26],
|
||||
'line-blur': ['interpolate', ['linear'], ['zoom'], 3, 0.5, 6, 0.35, 10, 0.15],
|
||||
'line-width': ['interpolate', ['linear'], ['zoom'], 3, 0.25, 6, 0.4, 8, 0.55, 12, 0.85],
|
||||
},
|
||||
} as unknown as LayerSpecification;
|
||||
|
||||
// === Labels — 2-tier LOD ===
|
||||
// z6-z9: 1000m, 2000m 라벨만
|
||||
const bathyLabelsCoarse: LayerSpecification = {
|
||||
id: 'bathymetry-labels-coarse',
|
||||
type: 'symbol',
|
||||
source: oceanSourceId,
|
||||
'source-layer': 'contour_line',
|
||||
minzoom: 6,
|
||||
maxzoom: 9,
|
||||
filter: depthIn(DEPTHS_COARSE) as unknown as unknown[],
|
||||
layout: {
|
||||
'symbol-placement': 'line',
|
||||
'text-field': depthLabel,
|
||||
'text-font': ['Noto Sans Regular', 'Open Sans Regular'],
|
||||
'text-size': ['interpolate', ['linear'], ['zoom'], 6, 10, 9, 12],
|
||||
'text-allow-overlap': false,
|
||||
'text-padding': 4,
|
||||
'text-rotation-alignment': 'map',
|
||||
},
|
||||
paint: {
|
||||
'text-color': 'rgba(226,232,240,0.78)',
|
||||
'text-halo-color': 'rgba(2,6,23,0.88)',
|
||||
'text-halo-width': 1.2,
|
||||
'text-halo-blur': 0.5,
|
||||
},
|
||||
} as unknown as LayerSpecification;
|
||||
|
||||
// z9+: 100~4000m 라벨
|
||||
const bathyLabels: LayerSpecification = {
|
||||
id: 'bathymetry-labels',
|
||||
type: 'symbol',
|
||||
source: oceanSourceId,
|
||||
'source-layer': 'contour_line',
|
||||
minzoom: 9,
|
||||
filter: depthIn(DEPTHS_MEDIUM) as unknown as unknown[],
|
||||
minzoom: 7,
|
||||
filter: bathyMajorDepthFilter as unknown as unknown[],
|
||||
layout: {
|
||||
'symbol-placement': 'line',
|
||||
'text-field': depthLabel,
|
||||
'text-font': ['Noto Sans Regular', 'Open Sans Regular'],
|
||||
'text-size': ['interpolate', ['linear'], ['zoom'], 9, 12, 11, 14, 13, 16],
|
||||
'text-size': ['interpolate', ['linear'], ['zoom'], 7, 10, 9, 12, 11, 14, 13, 16],
|
||||
'text-allow-overlap': false,
|
||||
'text-padding': 4,
|
||||
'text-rotation-alignment': 'map',
|
||||
@ -326,12 +244,10 @@ export function injectOceanBathymetryLayers(style: StyleSpecification, maptilerK
|
||||
|
||||
const toInsert = [
|
||||
bathyFill,
|
||||
bathyBordersMajor,
|
||||
bathyBorders,
|
||||
bathyLinesCoarse,
|
||||
bathyBandBorders,
|
||||
bathyBandBordersMajor,
|
||||
bathyLinesMinor,
|
||||
bathyLinesMajor,
|
||||
bathyLinesDetail,
|
||||
bathyLabelsCoarse,
|
||||
bathyLabels,
|
||||
landformLabels,
|
||||
].filter((l) => !existingIds.has(l.id));
|
||||
@ -357,7 +273,6 @@ export function applyBathymetryZoomProfile(map: maplibregl.Map, baseMap: BaseMap
|
||||
// ignore
|
||||
}
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
function applyKoreanLabels(style: StyleSpecification) {
|
||||
@ -383,7 +298,6 @@ export async function resolveInitialMapStyle(signal: AbortSignal): Promise<strin
|
||||
const res = await fetch(styleUrl, { signal, headers: { accept: 'application/json' } });
|
||||
if (!res.ok) throw new Error(`MapTiler style fetch failed: ${res.status} ${res.statusText}`);
|
||||
const json = (await res.json()) as StyleSpecification;
|
||||
applyLandLayerLOD(json);
|
||||
applyKoreanLabels(json);
|
||||
injectOceanBathymetryLayers(json, key);
|
||||
return json;
|
||||
|
||||
@ -38,19 +38,20 @@ export function destinationPointLngLat(
|
||||
return [outLon, outLat];
|
||||
}
|
||||
|
||||
export function circleRingLngLat(center: [number, number], radiusMeters: number, steps = 36): [number, number][] {
|
||||
// 반경이 지구 둘레의 1/4 (≈10,000km)을 넘으면 클램핑
|
||||
const r = clampNumber(radiusMeters, 0, EARTH_RADIUS_M * Math.PI * 0.5);
|
||||
export function circleRingLngLat(center: [number, number], radiusMeters: number, steps = 72): [number, number][] {
|
||||
const [lon0, lat0] = center;
|
||||
const latRad = lat0 * DEG2RAD;
|
||||
const cosLat = Math.max(1e-6, Math.cos(latRad));
|
||||
const r = Math.max(0, radiusMeters);
|
||||
|
||||
const ring: [number, number][] = [];
|
||||
for (let i = 0; i <= steps; i++) {
|
||||
const a = (i / steps) * Math.PI * 2;
|
||||
const pt = destinationPointLngLat(center, a * RAD2DEG, r);
|
||||
ring.push(pt);
|
||||
}
|
||||
// 고리 닫기 보정
|
||||
if (ring.length > 1) {
|
||||
ring[ring.length - 1] = ring[0];
|
||||
const dy = r * Math.sin(a);
|
||||
const dx = r * Math.cos(a);
|
||||
const dLat = (dy / EARTH_RADIUS_M) / DEG2RAD;
|
||||
const dLon = (dx / (EARTH_RADIUS_M * cosLat)) / DEG2RAD;
|
||||
ring.push([lon0 + dLon, lat0 + dLat]);
|
||||
}
|
||||
return ring;
|
||||
}
|
||||
|
||||
@ -24,10 +24,17 @@ export function removeSourceIfExists(map: maplibregl.Map, sourceId: string) {
|
||||
}
|
||||
}
|
||||
|
||||
// Ship 레이어/소스는 useGlobeShips에서 visibility 토글로 관리 (재생성 비용 회피)
|
||||
const GLOBE_NATIVE_LAYER_IDS = [
|
||||
'ships-globe-halo',
|
||||
'ships-globe-outline',
|
||||
'ships-globe',
|
||||
'ships-globe-label',
|
||||
'ships-globe-hover-halo',
|
||||
'ships-globe-hover-outline',
|
||||
'ships-globe-hover',
|
||||
'pair-lines-ml',
|
||||
'fc-lines-ml',
|
||||
'fleet-circles-ml-fill',
|
||||
'fleet-circles-ml',
|
||||
'pair-range-ml',
|
||||
'subcables-hitarea',
|
||||
@ -36,23 +43,19 @@ const GLOBE_NATIVE_LAYER_IDS = [
|
||||
'subcables-glow',
|
||||
'subcables-points',
|
||||
'subcables-label',
|
||||
'vessel-track-line',
|
||||
'vessel-track-line-hitarea',
|
||||
'vessel-track-arrow',
|
||||
'vessel-track-pts',
|
||||
'vessel-track-pts-highlight',
|
||||
'deck-globe',
|
||||
];
|
||||
|
||||
const GLOBE_NATIVE_SOURCE_IDS = [
|
||||
'ships-globe-src',
|
||||
'ships-globe-hover-src',
|
||||
'pair-lines-ml-src',
|
||||
'fc-lines-ml-src',
|
||||
'fleet-circles-ml-src',
|
||||
'fleet-circles-ml-fill-src',
|
||||
'pair-range-ml-src',
|
||||
'subcables-src',
|
||||
'subcables-pts-src',
|
||||
'vessel-track-line-src',
|
||||
'vessel-track-pts-src',
|
||||
];
|
||||
|
||||
export function clearGlobeNativeLayers(map: maplibregl.Map) {
|
||||
@ -101,22 +104,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(
|
||||
map: maplibregl.Map,
|
||||
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 { LegacyVesselInfo } from '../../../entities/legacyVessel/model/types';
|
||||
import { fmtIsoFull } from '../../../shared/lib/datetime';
|
||||
import { isFiniteNumber, toSafeNumber } from './setUtils';
|
||||
|
||||
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>MMSI: <b>${mmsi}</b>${vesselType ? ` · ${vesselType}` : ''}</div>
|
||||
<div>SOG: <b>${sog ?? '?'}</b> kt · COG: <b>${cog ?? '?'}</b>°</div>
|
||||
${msg ? `<div style="opacity:.7; font-size: 11px; margin-top: 4px;">${fmtIsoFull(msg)}</div>` : ''}
|
||||
${msg ? `<div style="opacity:.7; font-size: 11px; margin-top: 4px;">${msg}</div>` : ''}
|
||||
${legacyHtml}
|
||||
</div>`,
|
||||
};
|
||||
@ -168,54 +167,3 @@ export function getFleetCircleTooltipHtml({
|
||||
</div>`,
|
||||
};
|
||||
}
|
||||
|
||||
function fmtMinutesKr(minutes: number): string {
|
||||
if (minutes < 60) return `${minutes}분`;
|
||||
if (minutes < 1440) return `${Math.round(minutes / 60)}시간`;
|
||||
return `${Math.round(minutes / 1440)}일`;
|
||||
}
|
||||
|
||||
export function getTrackLineTooltipHtml({
|
||||
name,
|
||||
pointCount,
|
||||
minutes,
|
||||
totalDistanceNm,
|
||||
}: {
|
||||
name: string;
|
||||
pointCount: number;
|
||||
minutes: number;
|
||||
totalDistanceNm: number;
|
||||
}) {
|
||||
return {
|
||||
html: `<div style="font-family: system-ui; font-size: 12px;">
|
||||
<div style="font-weight: 700; margin-bottom: 4px;">항적 · ${name}</div>
|
||||
<div>기간: <b>${fmtMinutesKr(minutes)}</b> · 포인트: <b>${pointCount}</b></div>
|
||||
<div>총 거리: <b>${totalDistanceNm.toFixed(1)} NM</b></div>
|
||||
</div>`,
|
||||
};
|
||||
}
|
||||
|
||||
export function getTrackPointTooltipHtml({
|
||||
name,
|
||||
sog,
|
||||
cog,
|
||||
heading,
|
||||
status,
|
||||
messageTimestamp,
|
||||
}: {
|
||||
name: string;
|
||||
sog: number;
|
||||
cog: number;
|
||||
heading: number;
|
||||
status: string;
|
||||
messageTimestamp: string;
|
||||
}) {
|
||||
return {
|
||||
html: `<div style="font-family: system-ui; font-size: 12px; white-space: nowrap;">
|
||||
<div style="font-weight: 700; margin-bottom: 4px;">${name}</div>
|
||||
<div>SOG: <b>${isFiniteNumber(sog) ? sog : '?'}</b> kt · COG: <b>${isFiniteNumber(cog) ? cog : '?'}</b>°</div>
|
||||
<div>Heading: <b>${isFiniteNumber(heading) ? heading : '?'}</b>° · 상태: ${status || '-'}</div>
|
||||
<div style="opacity:.7; font-size: 11px; margin-top: 4px;">${fmtIsoFull(messageTimestamp)}</div>
|
||||
</div>`,
|
||||
};
|
||||
}
|
||||
|
||||
@ -1,7 +1,6 @@
|
||||
import type { AisTarget } from '../../entities/aisTarget/model/types';
|
||||
import type { LegacyVesselInfo } from '../../entities/legacyVessel/model/types';
|
||||
import type { SubcableGeoJson } from '../../entities/subcable/model/types';
|
||||
import type { ActiveTrack } from '../../entities/vesselTrack/model/types';
|
||||
import type { ZonesGeoJson } from '../../entities/zone/api/useZones';
|
||||
import type { MapToggleState } from '../../features/mapToggles/MapToggles';
|
||||
import type { FcLink, FleetCircle, PairLink } from '../../features/legacyDashboard/model/types';
|
||||
@ -16,13 +15,6 @@ export type Map3DSettings = {
|
||||
export type BaseMapId = 'enhanced' | 'legacy';
|
||||
export type MapProjectionId = 'mercator' | 'globe';
|
||||
|
||||
export interface MapViewState {
|
||||
center: [number, number]; // [lon, lat]
|
||||
zoom: number;
|
||||
bearing: number;
|
||||
pitch: number;
|
||||
}
|
||||
|
||||
export interface Map3DProps {
|
||||
targets: AisTarget[];
|
||||
zones: ZonesGeoJson | null;
|
||||
@ -60,14 +52,6 @@ export interface Map3DProps {
|
||||
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 = {
|
||||
|
||||
@ -11,161 +11,97 @@ export function SubcableInfoPanel({ detail, color, onClose }: Props) {
|
||||
const countries = [...new Set(detail.landing_points.map((lp) => lp.country).filter(Boolean))];
|
||||
|
||||
return (
|
||||
<div className="map-info" style={{ width: 320 }}>
|
||||
<div className="map-info" style={{ maxWidth: 340 }}>
|
||||
<button className="close-btn" onClick={onClose} aria-label="close">
|
||||
✕
|
||||
</button>
|
||||
|
||||
{/* ── Header ── */}
|
||||
<div style={{ marginBottom: 10, paddingRight: 20 }}>
|
||||
<div style={{ marginBottom: 8 }}>
|
||||
<div style={{ display: 'flex', alignItems: 'center', gap: 8 }}>
|
||||
{color && (
|
||||
<div
|
||||
style={{
|
||||
width: 14,
|
||||
height: 14,
|
||||
width: 12,
|
||||
height: 12,
|
||||
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 style={{ fontSize: 16, fontWeight: 900, color: 'var(--accent)' }}>{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
|
||||
<div style={{ fontSize: 10, color: 'var(--muted)', marginTop: 2 }}>
|
||||
Submarine Cable{detail.is_planned ? ' (Planned)' : ''}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="ir">
|
||||
<span className="il">길이</span>
|
||||
<span className="iv">{detail.length || '-'}</span>
|
||||
</div>
|
||||
<div className="ir">
|
||||
<span className="il">개통</span>
|
||||
<span className="iv">{detail.rfs || '-'}</span>
|
||||
</div>
|
||||
{detail.owners && (
|
||||
<div className="ir" style={{ alignItems: 'flex-start' }}>
|
||||
<span className="il">운영사</span>
|
||||
<span className="iv" style={{ wordBreak: 'break-word' }}>
|
||||
{detail.owners}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
{detail.suppliers && (
|
||||
<div className="ir">
|
||||
<span className="il">공급사</span>
|
||||
<span className="iv">{detail.suppliers}</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 style={{ marginTop: 8 }}>
|
||||
<div style={{ fontSize: 10, fontWeight: 700, color: 'var(--muted)', marginBottom: 4 }}>
|
||||
Landing Points ({landingCount}) · {countries.length} countries
|
||||
</div>
|
||||
<div
|
||||
style={{
|
||||
maxHeight: 160,
|
||||
maxHeight: 140,
|
||||
overflowY: 'auto',
|
||||
display: 'flex',
|
||||
flexDirection: 'column',
|
||||
gap: 2,
|
||||
fontSize: 10,
|
||||
lineHeight: 1.6,
|
||||
color: 'var(--text)',
|
||||
}}
|
||||
>
|
||||
{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 key={lp.id}>
|
||||
<span style={{ color: 'var(--muted)' }}>{lp.country}</span>{' '}
|
||||
<b>{lp.name}</b>
|
||||
{lp.is_tbd && <span style={{ color: '#F59E0B', marginLeft: 4 }}>TBD</span>}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* ── Notes ── */}
|
||||
{detail.notes && (
|
||||
<div
|
||||
style={{
|
||||
marginTop: 10,
|
||||
fontSize: 10,
|
||||
color: 'var(--muted)',
|
||||
fontStyle: 'italic',
|
||||
lineHeight: 1.5,
|
||||
}}
|
||||
>
|
||||
<div style={{ marginTop: 8, fontSize: 10, color: 'var(--muted)', fontStyle: 'italic' }}>
|
||||
{detail.notes}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* ── Link ── */}
|
||||
{detail.url && (
|
||||
<div style={{ marginTop: 10 }}>
|
||||
<div style={{ marginTop: 8 }}>
|
||||
<a
|
||||
href={detail.url}
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
style={{ fontSize: 10, color: 'var(--accent)', textDecoration: 'none' }}
|
||||
style={{ fontSize: 10, color: 'var(--accent)' }}
|
||||
>
|
||||
상세정보 ↗
|
||||
Official website ↗
|
||||
</a>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function InfoRow({ label, value, wrap }: { label: string; value: string | null; wrap?: boolean }) {
|
||||
return (
|
||||
<div className="ir" style={wrap ? { alignItems: 'flex-start' } : undefined}>
|
||||
<span className="il">{label}</span>
|
||||
<span
|
||||
className="iv"
|
||||
style={wrap ? { textAlign: 'right', wordBreak: 'break-word', maxWidth: '65%' } : undefined}
|
||||
>
|
||||
{value || '-'}
|
||||
</span>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
@ -9,11 +9,9 @@ type Props = {
|
||||
clock: string;
|
||||
adminMode?: boolean;
|
||||
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 =
|
||||
pollingStatus === "ready" ? "#22C55E" : pollingStatus === "loading" ? "#F59E0B" : pollingStatus === "error" ? "#EF4444" : "var(--muted)";
|
||||
return (
|
||||
@ -49,16 +47,6 @@ export function Topbar({ total, fishing, transit, pairLinks, alarms, pollingStat
|
||||
</div>
|
||||
</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>
|
||||
);
|
||||
}
|
||||
|
||||
891
package-lock.json
generated
891
package-lock.json
generated
파일 크기가 너무 크기때문에 변경 상태를 표시하지 않습니다.
Load Diff
불러오는 중...
Reference in New Issue
Block a user