diff --git a/CLAUDE.md b/CLAUDE.md index 164c874..52e1587 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -2,34 +2,22 @@ ## 프로젝트 개요 -- **타입**: React + TypeScript + Vite (모노레포) +- **타입**: React 19 + TypeScript 5.9 + Vite 7 (모노레포) - **Node.js**: `.node-version` 참조 (v24) - **패키지 매니저**: npm (workspaces) -- **구조**: apps/web (프론트엔드) + apps/api (백엔드 API) +- **구조**: apps/web (프론트엔드) + apps/api (백엔드 API 프록시) ## 빌드 및 실행 ```bash -# 의존성 설치 -npm install - -# 전체 개발 서버 -npm run dev - -# 개별 개발 서버 -npm run dev:web # 프론트엔드 (Vite) -npm run dev:api # 백엔드 (Fastify + tsx watch) - -# 빌드 -npm run build # 전체 빌드 (web + api) -npm run build:web # 프론트엔드만 -npm run build:api # 백엔드만 - -# 린트 -npm run lint # apps/web ESLint - -# 데이터 준비 -npm run prepare:data +npm install # 의존성 설치 +npm run dev # 전체 개발 서버 +npm run dev:web # 프론트엔드 (Vite) +npm run dev:api # 백엔드 (Fastify + tsx watch) +npm run build # 전체 빌드 (web + api) +npm run build:web # 프론트엔드만 +npm run lint # apps/web ESLint +npm run prepare:data # 정적 데이터 준비 ``` ## 프로젝트 구조 @@ -37,19 +25,18 @@ npm run prepare:data ``` gc-wing-dev/ ├── apps/ -│ ├── web/ # React 19 + Vite 7 + MapLibre + Deck.gl +│ ├── web/ # @wing/web - React 19 + Vite 7 │ │ └── src/ -│ │ ├── app/ # App.tsx, styles -│ │ ├── entities/ # 도메인 모델 (vessel, zone, aisTarget, legacyVessel) -│ │ ├── features/ # 기능 단위 (mapToggles, typeFilter, aisPolling 등) -│ │ ├── pages/ # 페이지 (DashboardPage) -│ │ ├── shared/ # 공통 유틸 (lib/geo, lib/color, lib/map) -│ │ └── widgets/ # UI 위젯 (map3d, vesselList, info, alarms 등) -│ └── api/ # Fastify 5 + TypeScript -│ └── src/ -│ └── index.ts +│ │ ├── app/ # App.tsx, styles.css +│ │ ├── entities/ # 도메인 모델 (aisTarget, vessel, zone, legacyVessel, subcable) +│ │ ├── features/ # 기능 모듈 (aisPolling, legacyDashboard, map3dSettings, mapSettings, mapToggles, typeFilter) +│ │ ├── pages/ # dashboard, login, denied, pending +│ │ ├── shared/ # auth (Google OAuth), lib (geo, color, map), hooks (usePersistedState) +│ │ └── widgets/ # map3d, vesselList, info, alarms, relations, aisInfo, aisTargetList, topbar, speed, legend, subcableInfo +│ └── api/ # @wing/api - Fastify 5 +│ └── src/index.ts # AIS 프록시 + zones 엔드포인트 ├── data/ # 정적 데이터 -├── scripts/ # 빌드 스크립트 (prepare-zones, prepare-legacy) +├── scripts/ # prepare-zones.mjs, prepare-legacy.mjs └── legacy/ # 레거시 데이터 ``` @@ -59,6 +46,7 @@ gc-wing-dev/ |------|------| | 프론트엔드 | React 19, Vite 7, TypeScript 5.9 | | 지도 | MapLibre GL JS 5, Deck.gl 9 | +| 인증 | Google OAuth (AuthProvider + ProtectedRoute) | | 백엔드 | Fastify 5, TypeScript | | 린트 | ESLint 9, Prettier | diff --git a/apps/web/src/app/styles.css b/apps/web/src/app/styles.css index a57ba8d..127b3dc 100644 --- a/apps/web/src/app/styles.css +++ b/apps/web/src/app/styles.css @@ -105,6 +105,7 @@ body { .map-area { position: relative; + background: #010610; } .sb { diff --git a/apps/web/src/entities/aisTarget/api/searchChnprmship.ts b/apps/web/src/entities/aisTarget/api/searchChnprmship.ts new file mode 100644 index 0000000..272e3c7 --- /dev/null +++ b/apps/web/src/entities/aisTarget/api/searchChnprmship.ts @@ -0,0 +1,32 @@ +import type { AisTargetSearchResponse } from '../model/types'; + +export async function searchChnprmship( + params: { minutes: number }, + signal?: AbortSignal, +): Promise { + 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; +} diff --git a/apps/web/src/features/aisPolling/useAisTargetPolling.ts b/apps/web/src/features/aisPolling/useAisTargetPolling.ts index 8524e65..59b1aff 100644 --- a/apps/web/src/features/aisPolling/useAisTargetPolling.ts +++ b/apps/web/src/features/aisPolling/useAisTargetPolling.ts @@ -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, 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, diff --git a/apps/web/src/pages/dashboard/DashboardPage.tsx b/apps/web/src/pages/dashboard/DashboardPage.tsx index eabce78..a66abc6 100644 --- a/apps/web/src/pages/dashboard/DashboardPage.tsx +++ b/apps/web/src/pages/dashboard/DashboardPage.tsx @@ -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(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([]); const [hoveredPairMmsiSet, setHoveredPairMmsiSet] = useState([]); const [hoveredFleetOwnerKey, setHoveredFleetOwnerKey] = useState(null); - const [typeEnabled, setTypeEnabled] = useState>({ - 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>( + 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("enhanced"); - const [projection, setProjection] = useState("mercator"); - const [mapStyleSettings, setMapStyleSettings] = useState(DEFAULT_MAP_STYLE_SETTINGS); + // 항상 mercator로 시작 — 3D 전환은 globe 레이어 준비 후 사용자가 수동 전환 + const [projection, setProjection] = useState('mercator'); + const [mapStyleSettings, setMapStyleSettings] = usePersistedState(uid, 'mapStyleSettings', DEFAULT_MAP_STYLE_SETTINGS); - const [overlays, setOverlays] = useState({ - pairLines: true, - pairRange: true, - fcLines: true, - zones: true, - fleetCircles: true, - predictVectors: true, - shipLabels: true, - subcables: false, + const [overlays, setOverlays] = usePersistedState(uid, 'overlays', { + pairLines: true, pairRange: true, fcLines: true, zones: true, + fleetCircles: true, predictVectors: true, shipLabels: true, subcables: false, }); - const [fleetRelationSortMode, setFleetRelationSortMode] = useState("count"); + const [fleetRelationSortMode, setFleetRelationSortMode] = usePersistedState(uid, 'sortMode', "count"); - const [alarmKindEnabled, setAlarmKindEnabled] = useState>(() => { - return Object.fromEntries(LEGACY_ALARM_KINDS.map((k) => [k, true])) as Record; - }); + const [alarmKindEnabled, setAlarmKindEnabled] = usePersistedState>( + uid, 'alarmKindEnabled', + () => Object.fromEntries(LEGACY_ALARM_KINDS.map((k) => [k, true])) as Record, + ); const [fleetFocus, setFleetFocus] = useState<{ id: string | number; center: [number, number]; zoom?: number } | undefined>(undefined); const [hoveredCableId, setHoveredCableId] = useState(null); const [selectedCableId, setSelectedCableId] = useState(null); - const [settings, setSettings] = useState({ - showShips: true, - showDensity: false, - showSeamark: false, + const [settings, setSettings] = usePersistedState(uid, 'map3dSettings', { + showShips: true, showDensity: false, showSeamark: false, }); + const [mapView, setMapView] = usePersistedState(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() { 지도 표시 설정
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
@@ -547,7 +541,7 @@ export function DashboardPage() {
최근 fetch
- {fmtLocal(snapshot.lastFetchAt)}{" "} + {fmtIsoFull(snapshot.lastFetchAt)}{" "} ({snapshot.lastFetchMinutes ?? "-"}m, inserted {snapshot.lastInserted}, upserted {snapshot.lastUpserted}) @@ -571,7 +565,7 @@ export function DashboardPage() { / {targetsInScope.length}
생성시각
-
{legacyData?.generatedAt ? fmtLocal(legacyData.generatedAt) : "loading..."}
+
{legacyData?.generatedAt ? fmtIsoFull(legacyData.generatedAt) : "loading..."}
)} @@ -682,7 +676,7 @@ export function DashboardPage() {
- {isProjectionLoading ? ( + {showMapLoader ? (
@@ -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} /> diff --git a/apps/web/src/shared/hooks/index.ts b/apps/web/src/shared/hooks/index.ts new file mode 100644 index 0000000..2f9fca0 --- /dev/null +++ b/apps/web/src/shared/hooks/index.ts @@ -0,0 +1 @@ +export { usePersistedState } from './usePersistedState'; diff --git a/apps/web/src/shared/hooks/usePersistedState.ts b/apps/web/src/shared/hooks/usePersistedState.ts new file mode 100644 index 0000000..d250bad --- /dev/null +++ b/apps/web/src/shared/hooks/usePersistedState.ts @@ -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(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(key: string, value: T): void { + try { + localStorage.setItem(key, JSON.stringify(value)); + } catch { + // quota exceeded or unavailable — silent + } +} + +function resolveDefault(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( + userId: number | null, + name: string, + defaultValue: T | (() => T), + debounceMs = 300, +): [T, Dispatch>] { + const resolved = resolveDefault(defaultValue); + + const [state, setState] = useState(() => { + if (userId == null) return resolved; + return readStorage(buildKey(userId, name), resolved); + }); + + const timerRef = useRef | 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]; +} diff --git a/apps/web/src/shared/lib/datetime.ts b/apps/web/src/shared/lib/datetime.ts new file mode 100644 index 0000000..5d532c2 --- /dev/null +++ b/apps/web/src/shared/lib/datetime.ts @@ -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 = {}; + 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 }); +} diff --git a/apps/web/src/widgets/aisInfo/AisInfoPanel.tsx b/apps/web/src/widgets/aisInfo/AisInfoPanel.tsx index 1f1bc51..7df8139 100644 --- a/apps/web/src/widgets/aisInfo/AisInfoPanel.tsx +++ b/apps/web/src/widgets/aisInfo/AisInfoPanel.tsx @@ -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) {
Msg TS - {t.messageTimestamp || "-"} + {fmtIsoFull(t.messageTimestamp)}
Received - {t.receivedDate || "-"} + {fmtIsoFull(t.receivedDate)}
); diff --git a/apps/web/src/widgets/aisTargetList/AisTargetList.tsx b/apps/web/src/widgets/aisTargetList/AisTargetList.tsx index d9b79af..67fa2c1 100644 --- a/apps/web/src/widgets/aisTargetList/AisTargetList.tsx +++ b/apps/web/src/widgets/aisTargetList/AisTargetList.tsx @@ -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("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 || ""; diff --git a/apps/web/src/widgets/info/VesselInfoPanel.tsx b/apps/web/src/widgets/info/VesselInfoPanel.tsx index 845640e..5920a9d 100644 --- a/apps/web/src/widgets/info/VesselInfoPanel.tsx +++ b/apps/web/src/widgets/info/VesselInfoPanel.tsx @@ -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 }
Msg TS - {v.messageTimestamp || "-"} + {fmtIsoFull(v.messageTimestamp)}
소유주 diff --git a/apps/web/src/widgets/map3d/Map3D.tsx b/apps/web/src/widgets/map3d/Map3D.tsx index c3a534f..ae66351 100644 --- a/apps/web/src/widgets/map3d/Map3D.tsx +++ b/apps/web/src/widgets/map3d/Map3D.tsx @@ -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, }, ); diff --git a/apps/web/src/widgets/map3d/hooks/useBaseMapToggle.ts b/apps/web/src/widgets/map3d/hooks/useBaseMapToggle.ts index 9844603..b9f3287 100644 --- a/apps/web/src/widgets/map3d/hooks/useBaseMapToggle.ts +++ b/apps/web/src/widgets/map3d/hooks/useBaseMapToggle.ts @@ -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 diff --git a/apps/web/src/widgets/map3d/hooks/useDeckLayers.ts b/apps/web/src/widgets/map3d/hooks/useDeckLayers.ts index 4ab3b49..af52639 100644 --- a/apps/web/src/widgets/map3d/hooks/useDeckLayers.ts +++ b/apps/web/src/widgets/map3d/hooks/useDeckLayers.ts @@ -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, overlayRef: MutableRefObject, @@ -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[] = []; diff --git a/apps/web/src/widgets/map3d/hooks/useGlobeInteraction.ts b/apps/web/src/widgets/map3d/hooks/useGlobeInteraction.ts index 96892ae..90e571c 100644 --- a/apps/web/src/widgets/map3d/hooks/useGlobeInteraction.ts +++ b/apps/web/src/widgets/map3d/hooks/useGlobeInteraction.ts @@ -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) { diff --git a/apps/web/src/widgets/map3d/hooks/useGlobeOverlays.ts b/apps/web/src/widgets/map3d/hooks/useGlobeOverlays.ts index dad2779..db3768b 100644 --- a/apps/web/src/widgets/map3d/hooks/useGlobeOverlays.ts +++ b/apps/web/src/widgets/map3d/hooks/useGlobeOverlays.ts @@ -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 = { - 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); diff --git a/apps/web/src/widgets/map3d/hooks/useGlobeShips.ts b/apps/web/src/widgets/map3d/hooks/useGlobeShips.ts index b5b16fb..5d55bdb 100644 --- a/apps/web/src/widgets/map3d/hooks/useGlobeShips.ts +++ b/apps/web/src/widgets/map3d/hooks/useGlobeShips.ts @@ -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, @@ -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 => { + 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 | 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 = { - 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 diff --git a/apps/web/src/widgets/map3d/hooks/useMapInit.ts b/apps/web/src/widgets/map3d/hooks/useMapInit.ts index 26fb2de..0a276fd 100644 --- a/apps/web/src/widgets/map3d/hooks/useMapInit.ts +++ b/apps/web/src/widgets/map3d/hooks/useMapInit.ts @@ -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, @@ -23,10 +22,14 @@ export function useMapInit( showSeamark: boolean; onViewBboxChange?: (bbox: [number, number, number, number]) => void; setMapSyncEpoch: Dispatch>; + 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 | 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 }; } diff --git a/apps/web/src/widgets/map3d/hooks/useMapStyleSettings.ts b/apps/web/src/widgets/map3d/hooks/useMapStyleSettings.ts index 00feb3f..9edefc9 100644 --- a/apps/web/src/widgets/map3d/hooks/useMapStyleSettings.ts +++ b/apps/web/src/widgets/map3d/hooks/useMapStyleSettings.ts @@ -35,7 +35,7 @@ function applyLabelLanguage(map: maplibregl.Map, lang: MapLabelLanguage) { if (layer.type !== 'symbol') continue; const layout = (layer as { layout?: Record }).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); diff --git a/apps/web/src/widgets/map3d/hooks/useProjectionToggle.ts b/apps/web/src/widgets/map3d/hooks/useProjectionToggle.ts index 2b92733..9bc33de 100644 --- a/apps/web/src/widgets/map3d/hooks/useProjectionToggle.ts +++ b/apps/web/src/widgets/map3d/hooks/useProjectionToggle.ts @@ -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, @@ -15,14 +13,13 @@ export function useProjectionToggle( projectionBusyRef: MutableRefObject, 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 | 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; } diff --git a/apps/web/src/widgets/map3d/hooks/useZonesLayer.ts b/apps/web/src/widgets/map3d/hooks/useZonesLayer.ts index e1f19fd..645f2c1 100644 --- a/apps/web/src/widgets/map3d/hooks/useZonesLayer.ts +++ b/apps/web/src/widgets/map3d/hooks/useZonesLayer.ts @@ -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, @@ -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]); } diff --git a/apps/web/src/widgets/map3d/layers/bathymetry.ts b/apps/web/src/widgets/map3d/layers/bathymetry.ts index 4c4089b..7d5a48a 100644 --- a/apps/web/src/widgets/map3d/layers/bathymetry.ts +++ b/apps/web/src/widgets/map3d/layers/bathymetry.ts @@ -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 = { + '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; + 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 1) { + ring[ring.length - 1] = ring[0]; } return ring; } diff --git a/apps/web/src/widgets/map3d/lib/layerHelpers.ts b/apps/web/src/widgets/map3d/lib/layerHelpers.ts index 8a06564..f5277a2 100644 --- a/apps/web/src/widgets/map3d/lib/layerHelpers.ts +++ b/apps/web/src/widgets/map3d/lib/layerHelpers.ts @@ -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[], diff --git a/apps/web/src/widgets/map3d/lib/tooltips.ts b/apps/web/src/widgets/map3d/lib/tooltips.ts index fb06a29..5fe4996 100644 --- a/apps/web/src/widgets/map3d/lib/tooltips.ts +++ b/apps/web/src/widgets/map3d/lib/tooltips.ts @@ -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({
${name}
MMSI: ${mmsi}${vesselType ? ` · ${vesselType}` : ''}
SOG: ${sog ?? '?'} kt · COG: ${cog ?? '?'}°
- ${msg ? `
${msg}
` : ''} + ${msg ? `
${fmtIsoFull(msg)}
` : ''} ${legacyHtml}
`, }; diff --git a/apps/web/src/widgets/map3d/types.ts b/apps/web/src/widgets/map3d/types.ts index 16d1d1f..aa6394d 100644 --- a/apps/web/src/widgets/map3d/types.ts +++ b/apps/web/src/widgets/map3d/types.ts @@ -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 = {