Merge pull request 'release: v0.3 배포 (지도 최적화 + 설정 영속화)' (#13) from develop into main
All checks were successful
Build and Deploy Wing / build-and-deploy (push) Successful in 19s

Reviewed-on: #13
This commit is contained in:
htlee 2026-02-16 16:28:37 +09:00
커밋 29be79d7bf
26개의 변경된 파일869개의 추가작업 그리고 721개의 파일을 삭제

파일 보기

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

파일 보기

@ -105,6 +105,7 @@ body {
.map-area {
position: relative;
background: #010610;
}
.sb {

파일 보기

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

파일 보기

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

파일 보기

@ -1,5 +1,6 @@
import { useEffect, useMemo, useRef, useState } from "react";
import { useCallback, useEffect, useMemo, useRef, useState } from "react";
import { useAuth } from "../../shared/auth";
import { usePersistedState } from "../../shared/hooks";
import { useAisTargetPolling } from "../../features/aisPolling/useAisTargetPolling";
import { Map3DSettingsToggles } from "../../features/map3dSettings/Map3DSettingsToggles";
import type { MapToggleState } from "../../features/mapToggles/MapToggles";
@ -19,6 +20,7 @@ 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";
@ -29,6 +31,7 @@ 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,
@ -48,13 +51,6 @@ 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";
@ -87,11 +83,10 @@ export function DashboardPage() {
const [apiBbox, setApiBbox] = useState<string | undefined>(undefined);
const { targets, snapshot } = useAisTargetPolling({
initialMinutes: 60,
bootstrapMinutes: 10,
chnprmshipMinutes: 120,
incrementalMinutes: 2,
intervalMs: 60_000,
retentionMinutes: 90,
retentionMinutes: 120,
bbox: useApiBbox ? apiBbox : undefined,
centerLon: useApiBbox ? undefined : AIS_CENTER.lon,
centerLat: useApiBbox ? undefined : AIS_CENTER.lat,
@ -104,55 +99,54 @@ export function DashboardPage() {
const [hoveredFleetMmsiSet, setHoveredFleetMmsiSet] = useState<number[]>([]);
const [hoveredPairMmsiSet, setHoveredPairMmsiSet] = useState<number[]>([]);
const [hoveredFleetOwnerKey, setHoveredFleetOwnerKey] = useState<string | null>(null);
const [typeEnabled, setTypeEnabled] = useState<Record<VesselTypeCode, boolean>>({
PT: true,
"PT-S": true,
GN: true,
OT: true,
PS: true,
FC: true,
});
const [showTargets, setShowTargets] = useState(true);
const [showOthers, setShowOthers] = useState(false);
const uid = 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);
// 레거시 베이스맵 비활성 — 향후 위성/라이트 등 추가 시 재활용
// eslint-disable-next-line @typescript-eslint/no-unused-vars
const [baseMap, _setBaseMap] = useState<BaseMapId>("enhanced");
const [projection, setProjection] = useState<MapProjectionId>("mercator");
const [mapStyleSettings, setMapStyleSettings] = useState<MapStyleSettings>(DEFAULT_MAP_STYLE_SETTINGS);
// 항상 mercator로 시작 — 3D 전환은 globe 레이어 준비 후 사용자가 수동 전환
const [projection, setProjection] = useState<MapProjectionId>('mercator');
const [mapStyleSettings, setMapStyleSettings] = usePersistedState<MapStyleSettings>(uid, 'mapStyleSettings', DEFAULT_MAP_STYLE_SETTINGS);
const [overlays, setOverlays] = useState<MapToggleState>({
pairLines: true,
pairRange: true,
fcLines: true,
zones: true,
fleetCircles: true,
predictVectors: true,
shipLabels: true,
subcables: false,
const [overlays, setOverlays] = usePersistedState<MapToggleState>(uid, 'overlays', {
pairLines: true, pairRange: true, fcLines: true, zones: true,
fleetCircles: true, predictVectors: true, shipLabels: true, subcables: false,
});
const [fleetRelationSortMode, setFleetRelationSortMode] = useState<FleetRelationSortMode>("count");
const [fleetRelationSortMode, setFleetRelationSortMode] = usePersistedState<FleetRelationSortMode>(uid, 'sortMode', "count");
const [alarmKindEnabled, setAlarmKindEnabled] = useState<Record<LegacyAlarmKind, boolean>>(() => {
return Object.fromEntries(LEGACY_ALARM_KINDS.map((k) => [k, true])) as Record<LegacyAlarmKind, boolean>;
});
const [alarmKindEnabled, setAlarmKindEnabled] = usePersistedState<Record<LegacyAlarmKind, boolean>>(
uid, 'alarmKindEnabled',
() => Object.fromEntries(LEGACY_ALARM_KINDS.map((k) => [k, true])) as Record<LegacyAlarmKind, boolean>,
);
const [fleetFocus, setFleetFocus] = useState<{ id: string | number; center: [number, number]; zoom?: number } | undefined>(undefined);
const [hoveredCableId, setHoveredCableId] = useState<string | null>(null);
const [selectedCableId, setSelectedCableId] = useState<string | null>(null);
const [settings, setSettings] = useState<Map3DSettings>({
showShips: true,
showDensity: false,
showSeamark: false,
const [settings, setSettings] = usePersistedState<Map3DSettings>(uid, 'map3dSettings', {
showShips: true, showDensity: false, showSeamark: false,
});
const [mapView, setMapView] = usePersistedState<MapViewState | null>(uid, 'mapView', null);
const [isProjectionLoading, setIsProjectionLoading] = useState(false);
// 초기값 false: globe 레이어가 백그라운드에서 준비될 때까지 토글 비활성화
const [isGlobeShipsReady, setIsGlobeShipsReady] = useState(false);
const handleProjectionLoadingChange = useCallback((loading: boolean) => {
setIsProjectionLoading(loading);
}, []);
const showMapLoader = isProjectionLoading;
// globe 레이어 미준비 또는 전환 중일 때 토글 비활성화
const isProjectionToggleDisabled = !isGlobeShipsReady || isProjectionLoading;
const [clock, setClock] = useState(() => new Date().toLocaleString("ko-KR", { hour12: false }));
const [clock, setClock] = useState(() => fmtDateTimeFull(new Date()));
useEffect(() => {
const id = window.setInterval(() => setClock(new Date().toLocaleString("ko-KR", { hour12: false })), 1000);
const id = window.setInterval(() => setClock(fmtDateTimeFull(new Date())), 1000);
return () => window.clearInterval(id);
}, []);
@ -367,10 +361,10 @@ export function DashboardPage() {
<div style={{ flex: 1 }} />
<div
className={`tog-btn ${projection === "globe" ? "on" : ""}`}
onClick={() => setProjection((p) => (p === "globe" ? "mercator" : "globe"))}
title="3D 지구본 투영: 드래그로 회전, 휠로 확대/축소"
style={{ fontSize: 9, padding: "2px 8px" }}
className={`tog-btn ${projection === "globe" ? "on" : ""}${isProjectionToggleDisabled ? " disabled" : ""}`}
onClick={isProjectionToggleDisabled ? undefined : () => setProjection((p) => (p === "globe" ? "mercator" : "globe"))}
title={isProjectionToggleDisabled ? "3D 모드 준비 중..." : "3D 지구본 투영: 드래그로 회전, 휠로 확대/축소"}
style={{ fontSize: 9, padding: "2px 8px", opacity: isProjectionToggleDisabled ? 0.4 : 1, cursor: isProjectionToggleDisabled ? "not-allowed" : "pointer" }}
>
3D
</div>
@ -547,7 +541,7 @@ export function DashboardPage() {
</div>
<div style={{ marginTop: 6, color: "var(--muted)", fontSize: 10 }}> fetch</div>
<div>
{fmtLocal(snapshot.lastFetchAt)}{" "}
{fmtIsoFull(snapshot.lastFetchAt)}{" "}
<span style={{ color: "var(--muted)", fontSize: 10 }}>
({snapshot.lastFetchMinutes ?? "-"}m, inserted {snapshot.lastInserted}, upserted {snapshot.lastUpserted})
</span>
@ -571,7 +565,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 ? fmtLocal(legacyData.generatedAt) : "loading..."}</div>
<div style={{ fontSize: 10, color: "var(--text)" }}>{legacyData?.generatedAt ? fmtIsoFull(legacyData.generatedAt) : "loading..."}</div>
</div>
)}
</div>
@ -682,7 +676,7 @@ export function DashboardPage() {
</div>
<div className="map-area">
{isProjectionLoading ? (
{showMapLoader ? (
<div className="map-loader-overlay" role="status" aria-live="polite">
<div className="map-loader-overlay__panel">
<div className="map-loader-overlay__spinner" />
@ -714,7 +708,8 @@ export function DashboardPage() {
fcLinks={fcLinksForMap}
fleetCircles={fleetCirclesForMap}
fleetFocus={fleetFocus}
onProjectionLoadingChange={setIsProjectionLoading}
onProjectionLoadingChange={handleProjectionLoadingChange}
onGlobeShipsReady={setIsGlobeShipsReady}
onHoverMmsi={(mmsis) => setHoveredMmsiSet(setUniqueSorted(mmsis))}
onClearMmsiHover={() => setHoveredMmsiSet((prev) => (prev.length === 0 ? prev : []))}
onHoverPair={(pairMmsis) => setHoveredPairMmsiSet(setUniqueSorted(pairMmsis))}
@ -732,6 +727,8 @@ export function DashboardPage() {
onHoverCable={setHoveredCableId}
onClickCable={(id) => setSelectedCableId((prev) => (prev === id ? null : id))}
mapStyleSettings={mapStyleSettings}
initialView={mapView}
onViewStateChange={setMapView}
/>
<MapSettingsPanel value={mapStyleSettings} onChange={setMapStyleSettings} />
<DepthLegend depthStops={mapStyleSettings.depthStops} />

파일 보기

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

파일 보기

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

파일 보기

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

파일 보기

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

파일 보기

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

파일 보기

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

파일 보기

@ -66,6 +66,9 @@ export function Map3D({
onHoverCable,
onClickCable,
mapStyleSettings,
initialView,
onViewStateChange,
onGlobeShipsReady,
}: Props) {
void onHoverFleet;
void onClearFleetHover;
@ -434,15 +437,15 @@ export function Map3D({
}, [fleetCircles, hoveredFleetOwnerKeys, isHighlightedFleet, overlays.fleetCircles, highlightedMmsiSetCombined]);
// ── Hook orchestration ───────────────────────────────────────────────
const { ensureMercatorOverlay, clearGlobeNativeLayers, pulseMapSync } = useMapInit(
const { ensureMercatorOverlay, pulseMapSync } = useMapInit(
containerRef, mapRef, overlayRef, overlayInteractionRef, globeDeckLayerRef,
baseMapRef, projectionRef,
{ baseMap, projection, showSeamark: settings.showSeamark, onViewBboxChange, setMapSyncEpoch },
{ baseMap, projection, showSeamark: settings.showSeamark, onViewBboxChange, setMapSyncEpoch, initialView, onViewStateChange },
);
const reorderGlobeFeatureLayers = useProjectionToggle(
mapRef, overlayRef, overlayInteractionRef, globeDeckLayerRef, projectionBusyRef,
{ projection, clearGlobeNativeLayers, ensureMercatorOverlay, onProjectionLoadingChange, pulseMapSync, setMapSyncEpoch },
{ projection, ensureMercatorOverlay, onProjectionLoadingChange, pulseMapSync, setMapSyncEpoch },
);
useBaseMapToggle(
@ -472,6 +475,7 @@ export function Map3D({
shipOverlayLayerData, shipLayerData, shipByMmsi, mapSyncEpoch,
onSelectMmsi, onToggleHighlightMmsi, targets, overlays,
legacyHits, selectedMmsi, isBaseHighlightedMmsi, hasAuxiliarySelectModifier,
onGlobeShipsReady,
},
);

파일 보기

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

파일 보기

@ -51,6 +51,12 @@ import {
} from '../lib/tooltips';
import { sanitizeDeckLayerList } from '../lib/mapCore';
// NOTE:
// Globe mode now relies on MapLibre native overlays (useGlobeOverlays/useGlobeShips).
// Keep Deck custom-layer rendering disabled in globe to avoid projection-space artifacts.
const ENABLE_GLOBE_DECK_OVERLAYS = false;
export function useDeckLayers(
mapRef: MutableRefObject<import('maplibre-gl').Map | null>,
overlayRef: MutableRefObject<MapboxOverlay | null>,
@ -595,6 +601,16 @@ 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' || layerId === 'fleet-circles-ml-fill') {
if (layerId === 'fleet-circles-ml') {
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-fill',
'fleet-circles-ml',
'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-fill', 'fleet-circles-ml',
'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' || layerId === 'fleet-circles-ml-fill';
const isFleetLayer = layerId === 'fleet-circles-ml';
const isZoneLayer = layerId === 'zones-fill' || layerId === 'zones-line' || layerId === 'zones-label';
if (isShipLayer) {

파일 보기

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

파일 보기

@ -1,4 +1,4 @@
import { useEffect, useRef, type MutableRefObject } from 'react';
import { useEffect, useMemo, useRef, type MutableRefObject } from 'react';
import type maplibregl from 'maplibre-gl';
import type { GeoJSONSource, GeoJSONSourceSpecification, LayerSpecification } from 'maplibre-gl';
import type { AisTarget } from '../../../entities/aisTarget/model/types';
@ -25,6 +25,7 @@ import {
ensureFallbackShipImage,
} from '../lib/globeShipIcon';
import { clampNumber } from '../lib/geometry';
import { guardedSetVisibility } from '../lib/layerHelpers';
export function useGlobeShips(
mapRef: MutableRefObject<maplibregl.Map | null>,
@ -48,18 +49,81 @@ 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;
@ -209,110 +273,52 @@ export function useGlobeShips(
const symbolId = 'ships-globe';
const labelId = 'ships-globe-label';
const remove = () => {
// 레이어를 제거하지 않고 visibility만 'none'으로 설정
// guardedSetVisibility로 현재 값과 동일하면 호출 생략 (style._changed 방지)
const hide = () => {
for (const id of [labelId, symbolId, outlineId, haloId]) {
try {
if (map.getLayer(id)) map.removeLayer(id);
} catch {
// ignore
}
guardedSetVisibility(map, id, 'none');
}
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;
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);
}
}
kickRepaint(map);
};
const ensure = () => {
if (projectionBusyRef.current) return;
if (!map.isStyleLoaded()) return;
if (projection !== 'globe' || !settings.showShips) {
remove();
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 (!map.isStyleLoaded()) return;
if (globeShipsEpochRef.current !== mapSyncEpoch) {
globeShipsEpochRef.current = mapSyncEpoch;
}
@ -323,69 +329,8 @@ export function useGlobeShips(
console.warn('Ship icon image setup failed:', e);
}
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 || '',
},
};
}),
};
// 사전 계산된 GeoJSON 사용 (useMemo에서 projection과 무관하게 항상 준비됨)
const geojson = globeShipGeoJson;
try {
const existing = map.getSource(srcId) as GeoJSONSource | undefined;
@ -396,7 +341,6 @@ export function useGlobeShips(
return;
}
const visibility = settings.showShips ? 'visible' : 'none';
const before = undefined;
if (!map.getLayer(haloId)) {
@ -434,35 +378,8 @@ 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 {
@ -508,36 +425,8 @@ 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 {
@ -598,31 +487,9 @@ 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'], '']], ''],
@ -672,17 +539,14 @@ 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
reorderGlobeFeatureLayers();
// 레이어가 준비되었음을 알림 (projection과 무관 — 토글 버튼 활성화용)
onGlobeShipsReady?.(true);
if (projection === 'globe') {
reorderGlobeFeatureLayers();
}
kickRepaint(map);
};
@ -694,12 +558,12 @@ export function useGlobeShips(
projection,
settings.showShips,
overlays.shipLabels,
shipData,
legacyHits,
globeShipGeoJson,
selectedMmsi,
isBaseHighlightedMmsi,
mapSyncEpoch,
reorderGlobeFeatureLayers,
onGlobeShipsReady,
]);
// Globe hover overlay ships
@ -713,22 +577,10 @@ export function useGlobeShips(
const outlineId = 'ships-globe-hover-outline';
const symbolId = 'ships-globe-hover';
const remove = () => {
const hideHover = () => {
for (const id of [symbolId, outlineId, haloId]) {
try {
if (map.getLayer(id)) map.removeLayer(id);
} catch {
// ignore
}
guardedSetVisibility(map, id, 'none');
}
try {
if (map.getSource(srcId)) map.removeSource(srcId);
} catch {
// ignore
}
globeHoverShipSignatureRef.current = '';
reorderGlobeFeatureLayers();
kickRepaint(map);
};
const ensure = () => {
@ -736,7 +588,7 @@ export function useGlobeShips(
if (!map.isStyleLoaded()) return;
if (projection !== 'globe' || !settings.showShips || shipHoverOverlaySet.size === 0) {
remove();
hideHover();
return;
}
@ -751,7 +603,7 @@ export function useGlobeShips(
const hovered = shipLayerData.filter((t) => shipHoverOverlaySet.has(t.mmsi) && !!legacyHits?.has(t.mmsi));
if (hovered.length === 0) {
remove();
hideHover();
return;
}
const hoverSignature = hovered

파일 보기

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

파일 보기

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

파일 보기

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

파일 보기

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

파일 보기

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

파일 보기

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

파일 보기

@ -24,17 +24,10 @@ export function removeSourceIfExists(map: maplibregl.Map, sourceId: string) {
}
}
// Ship 레이어/소스는 useGlobeShips에서 visibility 토글로 관리 (재생성 비용 회피)
const GLOBE_NATIVE_LAYER_IDS = [
'ships-globe-halo',
'ships-globe-outline',
'ships-globe',
'ships-globe-label',
'ships-globe-hover-halo',
'ships-globe-hover-outline',
'ships-globe-hover',
'pair-lines-ml',
'fc-lines-ml',
'fleet-circles-ml-fill',
'fleet-circles-ml',
'pair-range-ml',
'subcables-hitarea',
@ -47,12 +40,9 @@ const GLOBE_NATIVE_LAYER_IDS = [
];
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',
@ -104,6 +94,22 @@ export function setLayerVisibility(map: maplibregl.Map, layerId: string, visible
}
}
/**
* setLayoutProperty('visibility') wrapper .
* MapLibre는 setLayoutProperty style._changed = true를
* symbol layer의 placement를 . text-allow-overlap:false
* , .
*/
export function guardedSetVisibility(map: maplibregl.Map, layerId: string, target: 'visible' | 'none') {
if (!map.getLayer(layerId)) return;
try {
if (map.getLayoutProperty(layerId, 'visibility') === target) return;
map.setLayoutProperty(layerId, 'visibility', target);
} catch {
// ignore
}
}
export function cleanupLayers(
map: maplibregl.Map,
layerIds: string[],

파일 보기

@ -1,5 +1,6 @@
import type { AisTarget } from '../../../entities/aisTarget/model/types';
import type { LegacyVesselInfo } from '../../../entities/legacyVessel/model/types';
import { fmtIsoFull } from '../../../shared/lib/datetime';
import { isFiniteNumber, toSafeNumber } from './setUtils';
export function formatNm(value: number | null | undefined) {
@ -54,7 +55,7 @@ export function getShipTooltipHtml({
<div style="font-weight: 700; margin-bottom: 4px;">${name}</div>
<div>MMSI: <b>${mmsi}</b>${vesselType ? ` · ${vesselType}` : ''}</div>
<div>SOG: <b>${sog ?? '?'}</b> kt · COG: <b>${cog ?? '?'}</b>°</div>
${msg ? `<div style="opacity:.7; font-size: 11px; margin-top: 4px;">${msg}</div>` : ''}
${msg ? `<div style="opacity:.7; font-size: 11px; margin-top: 4px;">${fmtIsoFull(msg)}</div>` : ''}
${legacyHtml}
</div>`,
};

파일 보기

@ -15,6 +15,13 @@ export type Map3DSettings = {
export type BaseMapId = 'enhanced' | 'legacy';
export type MapProjectionId = 'mercator' | 'globe';
export interface MapViewState {
center: [number, number]; // [lon, lat]
zoom: number;
bearing: number;
pitch: number;
}
export interface Map3DProps {
targets: AisTarget[];
zones: ZonesGeoJson | null;
@ -52,6 +59,9 @@ 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;
}
export type DashSeg = {