From cc807bb5f6c4045940a059a520de473cb0fed6c4 Mon Sep 17 00:00:00 2001 From: htlee Date: Mon, 16 Feb 2026 12:12:43 +0900 Subject: [PATCH 01/16] =?UTF-8?q?feat:=20KST=20=ED=83=80=EC=9E=84=EC=8A=A4?= =?UTF-8?q?=ED=83=AC=ED=94=84=20=ED=8F=AC=EB=A7=B7=20=EC=9C=A0=ED=8B=B8=20?= =?UTF-8?q?=EC=A0=81=EC=9A=A9?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit shared/lib/datetime.ts에 KST 고정 포맷 함수 추가. AIS 정보, 선박 목록, 대시보드 등의 날짜 표시를 로컬 포맷에서 KST 명시적 포맷으로 통일. Co-Authored-By: Claude Opus 4.6 --- .../web/src/pages/dashboard/DashboardPage.tsx | 16 ++---- apps/web/src/shared/lib/datetime.ts | 50 +++++++++++++++++++ apps/web/src/widgets/aisInfo/AisInfoPanel.tsx | 5 +- .../widgets/aisTargetList/AisTargetList.tsx | 10 +--- apps/web/src/widgets/info/VesselInfoPanel.tsx | 3 +- apps/web/src/widgets/map3d/lib/tooltips.ts | 3 +- 6 files changed, 64 insertions(+), 23 deletions(-) create mode 100644 apps/web/src/shared/lib/datetime.ts diff --git a/apps/web/src/pages/dashboard/DashboardPage.tsx b/apps/web/src/pages/dashboard/DashboardPage.tsx index 87e5bdb..dc4e1dc 100644 --- a/apps/web/src/pages/dashboard/DashboardPage.tsx +++ b/apps/web/src/pages/dashboard/DashboardPage.tsx @@ -28,6 +28,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, @@ -47,13 +48,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"; @@ -148,9 +142,9 @@ export function DashboardPage() { const [isProjectionLoading, setIsProjectionLoading] = useState(false); - 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); }, []); @@ -543,7 +537,7 @@ export function DashboardPage() {
최근 fetch
- {fmtLocal(snapshot.lastFetchAt)}{" "} + {fmtIsoFull(snapshot.lastFetchAt)}{" "} ({snapshot.lastFetchMinutes ?? "-"}m, inserted {snapshot.lastInserted}, upserted {snapshot.lastUpserted}) @@ -567,7 +561,7 @@ export function DashboardPage() { / {targetsInScope.length}
생성시각
-
{legacyData?.generatedAt ? fmtLocal(legacyData.generatedAt) : "loading..."}
+
{legacyData?.generatedAt ? fmtIsoFull(legacyData.generatedAt) : "loading..."}
)} 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/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}
`, }; From 16ebf3abca93f0836ea25efa049f397c63058b99 Mon Sep 17 00:00:00 2001 From: htlee Date: Mon, 16 Feb 2026 12:18:39 +0900 Subject: [PATCH 02/16] =?UTF-8?q?feat:=20=EC=82=AC=EC=9A=A9=EC=9E=90=20?= =?UTF-8?q?=EC=84=A4=EC=A0=95=20=EC=98=81=EC=86=8D=ED=99=94=20+=20?= =?UTF-8?q?=EC=A7=80=EB=8F=84=20=EB=B7=B0=20=EC=A0=80=EC=9E=A5?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit usePersistedState hook으로 대시보드 상태를 localStorage에 자동 저장. 지도 뷰(중심/줌/방위)도 60초 주기 + 언마운트 시 저장하여 새로고침 복원. Co-Authored-By: Claude Opus 4.6 --- .../web/src/pages/dashboard/DashboardPage.tsx | 52 ++++----- apps/web/src/shared/hooks/index.ts | 1 + .../web/src/shared/hooks/usePersistedState.ts | 103 ++++++++++++++++++ apps/web/src/widgets/map3d/Map3D.tsx | 4 +- .../web/src/widgets/map3d/hooks/useMapInit.ts | 32 +++++- apps/web/src/widgets/map3d/types.ts | 9 ++ 6 files changed, 166 insertions(+), 35 deletions(-) create mode 100644 apps/web/src/shared/hooks/index.ts create mode 100644 apps/web/src/shared/hooks/usePersistedState.ts diff --git a/apps/web/src/pages/dashboard/DashboardPage.tsx b/apps/web/src/pages/dashboard/DashboardPage.tsx index dc4e1dc..e85426f 100644 --- a/apps/web/src/pages/dashboard/DashboardPage.tsx +++ b/apps/web/src/pages/dashboard/DashboardPage.tsx @@ -1,4 +1,5 @@ import { useEffect, useMemo, useRef, useState } from "react"; +import { usePersistedState } from "../../shared/hooks"; import { useAisTargetPolling } from "../../features/aisPolling/useAisTargetPolling"; import { Map3DSettingsToggles } from "../../features/map3dSettings/Map3DSettingsToggles"; import type { MapToggleState } from "../../features/mapToggles/MapToggles"; @@ -18,6 +19,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"; @@ -96,49 +98,39 @@ 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); + const [projection, setProjection] = usePersistedState(uid, 'projection', "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); @@ -722,6 +714,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/widgets/map3d/Map3D.tsx b/apps/web/src/widgets/map3d/Map3D.tsx index c3a534f..ec2abef 100644 --- a/apps/web/src/widgets/map3d/Map3D.tsx +++ b/apps/web/src/widgets/map3d/Map3D.tsx @@ -66,6 +66,8 @@ export function Map3D({ onHoverCable, onClickCable, mapStyleSettings, + initialView, + onViewStateChange, }: Props) { void onHoverFleet; void onClearFleetHover; @@ -437,7 +439,7 @@ export function Map3D({ const { ensureMercatorOverlay, clearGlobeNativeLayers, 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( diff --git a/apps/web/src/widgets/map3d/hooks/useMapInit.ts b/apps/web/src/widgets/map3d/hooks/useMapInit.ts index 26fb2de..243ef55 100644 --- a/apps/web/src/widgets/map3d/hooks/useMapInit.ts +++ b/apps/web/src/widgets/map3d/hooks/useMapInit.ts @@ -2,7 +2,7 @@ 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 type { BaseMapId, MapProjectionId, MapViewState } from '../types'; import { DECK_VIEW_ID } from '../constants'; import { kickRepaint, onMapStyleReady } from '../lib/mapCore'; import { ensureSeamarkOverlay } from '../layers/seamark'; @@ -23,10 +23,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]); @@ -65,6 +69,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 +82,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, @@ -147,6 +153,14 @@ export function useMapInit( map.on('load', emitBbox); map.on('moveend', emitBbox); + // 60초 인터벌로 뷰 상태 저장 + viewSaveTimer = setInterval(() => { + const cb = onViewStateChangeRef.current; + if (!cb || !map) 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', () => { if (showSeamarkRef.current) { try { @@ -167,6 +181,14 @@ export function useMapInit( return () => { cancelled = true; controller.abort(); + if (viewSaveTimer) clearInterval(viewSaveTimer); + + // 최종 뷰 상태 저장 + const cb = onViewStateChangeRef.current; + if (cb && map) { + const c = map.getCenter(); + cb({ center: [c.lng, c.lat], zoom: map.getZoom(), bearing: map.getBearing(), pitch: map.getPitch() }); + } try { globeDeckLayerRef.current?.requestFinalize(); diff --git a/apps/web/src/widgets/map3d/types.ts b/apps/web/src/widgets/map3d/types.ts index 16d1d1f..4429e2f 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,8 @@ export interface Map3DProps { onHoverCable?: (cableId: string | null) => void; onClickCable?: (cableId: string | null) => void; mapStyleSettings?: MapStyleSettings; + initialView?: MapViewState | null; + onViewStateChange?: (view: MapViewState) => void; } export type DashSeg = { From 39d9cc9db1a3c9cb5a754d98613032f97457ba4f Mon Sep 17 00:00:00 2001 From: htlee Date: Mon, 16 Feb 2026 12:23:18 +0900 Subject: [PATCH 03/16] =?UTF-8?q?feat(ais):=20=EC=B4=88=EA=B8=B0=20?= =?UTF-8?q?=EB=A1=9C=EB=93=9C=EB=A5=BC=20chnprmship=20API=EB=A1=9C=20?= =?UTF-8?q?=EC=A0=84=ED=99=98?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-Authored-By: Claude Opus 4.6 --- .../aisTarget/api/searchChnprmship.ts | 32 +++++ .../aisPolling/useAisTargetPolling.ts | 109 ++++++++++-------- .../web/src/pages/dashboard/DashboardPage.tsx | 5 +- 3 files changed, 96 insertions(+), 50 deletions(-) create mode 100644 apps/web/src/entities/aisTarget/api/searchChnprmship.ts 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 e85426f..ca6de78 100644 --- a/apps/web/src/pages/dashboard/DashboardPage.tsx +++ b/apps/web/src/pages/dashboard/DashboardPage.tsx @@ -81,11 +81,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, From d88c89403d8a5933aa36486bc9e2ba4297f5d24c Mon Sep 17 00:00:00 2001 From: htlee Date: Mon, 16 Feb 2026 12:26:42 +0900 Subject: [PATCH 04/16] =?UTF-8?q?perf(map):=20globe=20=EC=84=A0=EB=B0=95?= =?UTF-8?q?=20=EB=A0=8C=EB=8D=94=EB=A7=81=20=EC=82=AC=EC=A0=84=EA=B3=84?= =?UTF-8?q?=EC=82=B0=20=EC=B5=9C=EC=A0=81=ED=99=94?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-Authored-By: Claude Opus 4.6 --- .../web/src/pages/dashboard/DashboardPage.tsx | 13 +- apps/web/src/widgets/map3d/Map3D.tsx | 2 + .../src/widgets/map3d/hooks/useGlobeShips.ts | 213 ++++++------------ .../web/src/widgets/map3d/hooks/useMapInit.ts | 42 +++- .../web/src/widgets/map3d/lib/layerHelpers.ts | 10 +- apps/web/src/widgets/map3d/types.ts | 1 + 6 files changed, 130 insertions(+), 151 deletions(-) diff --git a/apps/web/src/pages/dashboard/DashboardPage.tsx b/apps/web/src/pages/dashboard/DashboardPage.tsx index ca6de78..85da83c 100644 --- a/apps/web/src/pages/dashboard/DashboardPage.tsx +++ b/apps/web/src/pages/dashboard/DashboardPage.tsx @@ -1,4 +1,4 @@ -import { useEffect, useMemo, useRef, useState } from "react"; +import { useCallback, useEffect, useMemo, useRef, useState } from "react"; import { usePersistedState } from "../../shared/hooks"; import { useAisTargetPolling } from "../../features/aisPolling/useAisTargetPolling"; import { Map3DSettingsToggles } from "../../features/map3dSettings/Map3DSettingsToggles"; @@ -132,6 +132,12 @@ export function DashboardPage() { const [mapView, setMapView] = usePersistedState(uid, 'mapView', null); const [isProjectionLoading, setIsProjectionLoading] = useState(false); + const [isGlobeShipsReady, setIsGlobeShipsReady] = useState(true); + const handleProjectionLoadingChange = useCallback((loading: boolean) => { + setIsProjectionLoading(loading); + if (loading) setIsGlobeShipsReady(false); + }, []); + const showMapLoader = isProjectionLoading || (projection === "globe" && !isGlobeShipsReady); const [clock, setClock] = useState(() => fmtDateTimeFull(new Date())); useEffect(() => { @@ -663,7 +669,7 @@ export function DashboardPage() {
- {isProjectionLoading ? ( + {showMapLoader ? (
@@ -695,7 +701,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))} diff --git a/apps/web/src/widgets/map3d/Map3D.tsx b/apps/web/src/widgets/map3d/Map3D.tsx index ec2abef..721b7e6 100644 --- a/apps/web/src/widgets/map3d/Map3D.tsx +++ b/apps/web/src/widgets/map3d/Map3D.tsx @@ -68,6 +68,7 @@ export function Map3D({ mapStyleSettings, initialView, onViewStateChange, + onGlobeShipsReady, }: Props) { void onHoverFleet; void onClearFleetHover; @@ -474,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/useGlobeShips.ts b/apps/web/src/widgets/map3d/hooks/useGlobeShips.ts index b5b16fb..32b8a1d 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'; @@ -48,18 +48,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; @@ -227,81 +290,14 @@ export function useGlobeShips( kickRepaint(map); }; + // 이미지 보장: useMapInit에서 미리 로드됨 → 대부분 즉시 반환 + // 미리 로드되지 않았다면 fallback canvas 아이콘 사용 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); - } - } + // useMapInit에서 pre-load가 아직 완료되지 않은 경우 fallback으로 진행 + kickRepaint(map); }; const ensure = () => { @@ -310,6 +306,7 @@ export function useGlobeShips( if (projection !== 'globe' || !settings.showShips) { remove(); + onGlobeShipsReady?.(false); return; } @@ -323,69 +320,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; @@ -684,6 +620,7 @@ export function useGlobeShips( reorderGlobeFeatureLayers(); kickRepaint(map); + onGlobeShipsReady?.(true); }; const stop = onMapStyleReady(map, ensure); @@ -694,12 +631,12 @@ export function useGlobeShips( projection, settings.showShips, overlays.shipLabels, - shipData, - legacyHits, + globeShipGeoJson, selectedMmsi, isBaseHighlightedMmsi, mapSyncEpoch, reorderGlobeFeatureLayers, + onGlobeShipsReady, ]); // Globe hover overlay ships diff --git a/apps/web/src/widgets/map3d/hooks/useMapInit.ts b/apps/web/src/widgets/map3d/hooks/useMapInit.ts index 243ef55..372eadb 100644 --- a/apps/web/src/widgets/map3d/hooks/useMapInit.ts +++ b/apps/web/src/widgets/map3d/hooks/useMapInit.ts @@ -3,7 +3,7 @@ import maplibregl, { type StyleSpecification } from 'maplibre-gl'; import { MapboxOverlay } from '@deck.gl/mapbox'; import { MaplibreDeckCustomLayer } from '../MaplibreDeckCustomLayer'; import type { BaseMapId, MapProjectionId, MapViewState } from '../types'; -import { DECK_VIEW_ID } from '../constants'; +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'; @@ -100,6 +100,44 @@ 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 방어 + // symbol layer 추가/제거와 render cycle 간 타이밍 이슈로 발생하는 에러 억제 + { + const origRender = (map as unknown as { _render: (arg?: number) => void })._render; + (map as unknown as { _render: (arg?: number) => void })._render = function (arg?: number) { + try { + origRender.call(this, arg); + } catch (e) { + if (e instanceof TypeError && (e.message?.includes("reading 'get'") || e.message?.includes('placement'))) { + return; + } + throw e; + } + }; + } + + // 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') { @@ -175,6 +213,8 @@ export function useMapInit( // ignore } } + // 종속 hook들(useMapStyleSettings 등)이 저장된 설정을 적용하도록 트리거 + setMapSyncEpoch((prev) => prev + 1); }); })(); diff --git a/apps/web/src/widgets/map3d/lib/layerHelpers.ts b/apps/web/src/widgets/map3d/lib/layerHelpers.ts index 8a06564..a49ae3a 100644 --- a/apps/web/src/widgets/map3d/lib/layerHelpers.ts +++ b/apps/web/src/widgets/map3d/lib/layerHelpers.ts @@ -24,14 +24,8 @@ 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', @@ -47,8 +41,6 @@ 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', diff --git a/apps/web/src/widgets/map3d/types.ts b/apps/web/src/widgets/map3d/types.ts index 4429e2f..aa6394d 100644 --- a/apps/web/src/widgets/map3d/types.ts +++ b/apps/web/src/widgets/map3d/types.ts @@ -61,6 +61,7 @@ export interface Map3DProps { mapStyleSettings?: MapStyleSettings; initialView?: MapViewState | null; onViewStateChange?: (view: MapViewState) => void; + onGlobeShipsReady?: (ready: boolean) => void; } export type DashSeg = { From 91df90b52892e212c907cebcd822794d058aae7e Mon Sep 17 00:00:00 2001 From: htlee Date: Mon, 16 Feb 2026 13:08:54 +0900 Subject: [PATCH 05/16] =?UTF-8?q?perf(map):=20Globe/Mercator=20=EC=96=91?= =?UTF-8?q?=EB=B0=A9=ED=96=A5=20=EB=8F=99=EC=8B=9C=20=EB=A0=8C=EB=8D=94?= =?UTF-8?q?=EB=A7=81?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - overlay 파괴/재생성 대신 layers 비움으로 전환 - globe ship 레이어 visibility 즉시 토글 (projectionBusy 우회) - fleet circles fill vertex 초과 수정 (steps 72→36/24) - globe scrollZoom easing 경고 수정 - projection 비영속화 (항상 mercator 시작) - globe 레이어 준비 전까지 3D 토글 비활성화 Co-Authored-By: Claude Opus 4.6 --- .../web/src/pages/dashboard/DashboardPage.tsx | 19 ++-- apps/web/src/widgets/map3d/Map3D.tsx | 4 +- .../widgets/map3d/hooks/useGlobeOverlays.ts | 5 +- .../src/widgets/map3d/hooks/useGlobeShips.ts | 76 ++++++++-------- .../web/src/widgets/map3d/hooks/useMapInit.ts | 38 +++----- .../map3d/hooks/useProjectionToggle.ts | 87 +++++-------------- apps/web/src/widgets/map3d/lib/geometry.ts | 19 ++-- 7 files changed, 100 insertions(+), 148 deletions(-) diff --git a/apps/web/src/pages/dashboard/DashboardPage.tsx b/apps/web/src/pages/dashboard/DashboardPage.tsx index 85da83c..311f02d 100644 --- a/apps/web/src/pages/dashboard/DashboardPage.tsx +++ b/apps/web/src/pages/dashboard/DashboardPage.tsx @@ -107,7 +107,8 @@ export function DashboardPage() { // 레거시 베이스맵 비활성 — 향후 위성/라이트 등 추가 시 재활용 // eslint-disable-next-line @typescript-eslint/no-unused-vars const [baseMap, _setBaseMap] = useState("enhanced"); - const [projection, setProjection] = usePersistedState(uid, 'projection', "mercator"); + // 항상 mercator로 시작 — 3D 전환은 globe 레이어 준비 후 사용자가 수동 전환 + const [projection, setProjection] = useState('mercator'); const [mapStyleSettings, setMapStyleSettings] = usePersistedState(uid, 'mapStyleSettings', DEFAULT_MAP_STYLE_SETTINGS); const [overlays, setOverlays] = usePersistedState(uid, 'overlays', { @@ -132,12 +133,14 @@ export function DashboardPage() { const [mapView, setMapView] = usePersistedState(uid, 'mapView', null); const [isProjectionLoading, setIsProjectionLoading] = useState(false); - const [isGlobeShipsReady, setIsGlobeShipsReady] = useState(true); + // 초기값 false: globe 레이어가 백그라운드에서 준비될 때까지 토글 비활성화 + const [isGlobeShipsReady, setIsGlobeShipsReady] = useState(false); const handleProjectionLoadingChange = useCallback((loading: boolean) => { setIsProjectionLoading(loading); - if (loading) setIsGlobeShipsReady(false); }, []); - const showMapLoader = isProjectionLoading || (projection === "globe" && !isGlobeShipsReady); + const showMapLoader = isProjectionLoading; + // globe 레이어 미준비 또는 전환 중일 때 토글 비활성화 + const isProjectionToggleDisabled = !isGlobeShipsReady || isProjectionLoading; const [clock, setClock] = useState(() => fmtDateTimeFull(new Date())); useEffect(() => { @@ -354,10 +357,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
diff --git a/apps/web/src/widgets/map3d/Map3D.tsx b/apps/web/src/widgets/map3d/Map3D.tsx index 721b7e6..ae66351 100644 --- a/apps/web/src/widgets/map3d/Map3D.tsx +++ b/apps/web/src/widgets/map3d/Map3D.tsx @@ -437,7 +437,7 @@ 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, initialView, onViewStateChange }, @@ -445,7 +445,7 @@ export function Map3D({ const reorderGlobeFeatureLayers = useProjectionToggle( mapRef, overlayRef, overlayInteractionRef, globeDeckLayerRef, projectionBusyRef, - { projection, clearGlobeNativeLayers, ensureMercatorOverlay, onProjectionLoadingChange, pulseMapSync, setMapSyncEpoch }, + { projection, ensureMercatorOverlay, onProjectionLoadingChange, pulseMapSync, setMapSyncEpoch }, ); useBaseMapToggle( diff --git a/apps/web/src/widgets/map3d/hooks/useGlobeOverlays.ts b/apps/web/src/widgets/map3d/hooks/useGlobeOverlays.ts index dad2779..2803246 100644 --- a/apps/web/src/widgets/map3d/hooks/useGlobeOverlays.ts +++ b/apps/web/src/widgets/map3d/hooks/useGlobeOverlays.ts @@ -304,10 +304,13 @@ export function useGlobeOverlays( }), }; + // fill 폴리곤은 globe 테셀레이션으로 vertex 수가 급격히 증가하므로 + // 스텝 수를 줄이고 (24), 반경을 500nm으로 상한 설정 + const MAX_FILL_RADIUS_M = 500 * 1852; const fcFill: GeoJSON.FeatureCollection = { type: 'FeatureCollection', features: (fleetCircles || []).map((c) => { - const ring = circleRingLngLat(c.center, c.radiusNm * 1852); + const ring = circleRingLngLat(c.center, Math.min(c.radiusNm * 1852, MAX_FILL_RADIUS_M), 24); return { type: 'Feature', id: `${makeFleetCircleFeatureId(c.ownerKey)}-fill`, diff --git a/apps/web/src/widgets/map3d/hooks/useGlobeShips.ts b/apps/web/src/widgets/map3d/hooks/useGlobeShips.ts index 32b8a1d..0b6a08e 100644 --- a/apps/web/src/widgets/map3d/hooks/useGlobeShips.ts +++ b/apps/web/src/widgets/map3d/hooks/useGlobeShips.ts @@ -272,44 +272,49 @@ export function useGlobeShips( const symbolId = 'ships-globe'; const labelId = 'ships-globe-label'; - const remove = () => { + // 레이어를 제거하지 않고 visibility만 'none'으로 설정 + const hide = () => { for (const id of [labelId, symbolId, outlineId, haloId]) { try { - if (map.getLayer(id)) map.removeLayer(id); - } catch { - // ignore - } + if (map.getLayer(id)) map.setLayoutProperty(id, 'visibility', 'none'); + } catch { /* ignore */ } } - try { - if (map.getSource(srcId)) map.removeSource(srcId); - } catch { - // ignore - } - globeHoverShipSignatureRef.current = ''; - reorderGlobeFeatureLayers(); - kickRepaint(map); }; - // 이미지 보장: useMapInit에서 미리 로드됨 → 대부분 즉시 반환 - // 미리 로드되지 않았다면 fallback canvas 아이콘 사용 const ensureImage = () => { ensureFallbackShipImage(map, imgId); ensureFallbackShipImage(map, anchoredImgId, buildFallbackGlobeAnchoredShipIcon); if (map.hasImage(imgId) && map.hasImage(anchoredImgId)) return; - // useMapInit에서 pre-load가 아직 완료되지 않은 경우 fallback으로 진행 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 중에도 실행 + // 이미 생성된 레이어의 표시 상태만 즉시 전환하여 projection 전환 체감 속도 개선 + const visibility = projection === 'globe' ? 'visible' : 'none'; + const labelVisibility = projection === 'globe' && overlays.shipLabels ? 'visible' : 'none'; + if (map.getLayer(symbolId)) { + for (const id of [haloId, outlineId, symbolId]) { + try { map.setLayoutProperty(id, 'visibility', visibility); } catch { /* ignore */ } + } + try { map.setLayoutProperty(labelId, 'visibility', labelVisibility); } catch { /* ignore */ } + if (projection === 'globe') kickRepaint(map); + } + + // 데이터 업데이트는 projectionBusy 중에는 차단 + if (projectionBusyRef.current) { + // 레이어가 이미 존재하면 ready 상태 유지 + if (map.getLayer(symbolId)) onGlobeShipsReady?.(true); + return; + } + if (!map.isStyleLoaded()) return; + if (globeShipsEpochRef.current !== mapSyncEpoch) { globeShipsEpochRef.current = mapSyncEpoch; } @@ -332,7 +337,6 @@ export function useGlobeShips( return; } - const visibility = settings.showShips ? 'visible' : 'none'; const before = undefined; if (!map.getLayer(haloId)) { @@ -558,7 +562,6 @@ export function useGlobeShips( } } - const labelVisibility = overlays.shipLabels ? 'visible' : 'none'; const labelFilter = [ 'all', ['!=', ['to-string', ['coalesce', ['get', 'labelName'], '']], ''], @@ -618,9 +621,12 @@ export function useGlobeShips( } } - reorderGlobeFeatureLayers(); - kickRepaint(map); + // 레이어가 준비되었음을 알림 (projection과 무관 — 토글 버튼 활성화용) onGlobeShipsReady?.(true); + if (projection === 'globe') { + reorderGlobeFeatureLayers(); + } + kickRepaint(map); }; const stop = onMapStyleReady(map, ensure); @@ -650,22 +656,12 @@ 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 - } + if (map.getLayer(id)) map.setLayoutProperty(id, 'visibility', 'none'); + } catch { /* ignore */ } } - try { - if (map.getSource(srcId)) map.removeSource(srcId); - } catch { - // ignore - } - globeHoverShipSignatureRef.current = ''; - reorderGlobeFeatureLayers(); - kickRepaint(map); }; const ensure = () => { @@ -673,7 +669,7 @@ export function useGlobeShips( if (!map.isStyleLoaded()) return; if (projection !== 'globe' || !settings.showShips || shipHoverOverlaySet.size === 0) { - remove(); + hideHover(); return; } @@ -688,7 +684,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 372eadb..a9a325e 100644 --- a/apps/web/src/widgets/map3d/hooks/useMapInit.ts +++ b/apps/web/src/widgets/map3d/hooks/useMapInit.ts @@ -7,7 +7,6 @@ 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, @@ -50,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(() => { @@ -140,17 +133,13 @@ export function useMapInit( 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; @@ -166,8 +155,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 { @@ -191,10 +181,10 @@ export function useMapInit( map.on('load', emitBbox); map.on('moveend', emitBbox); - // 60초 인터벌로 뷰 상태 저장 + // 60초 인터벌로 뷰 상태 저장 (mercator일 때만) viewSaveTimer = setInterval(() => { const cb = onViewStateChangeRef.current; - if (!cb || !map) return; + 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); @@ -223,9 +213,9 @@ export function useMapInit( controller.abort(); if (viewSaveTimer) clearInterval(viewSaveTimer); - // 최종 뷰 상태 저장 + // 최종 뷰 상태 저장 (mercator일 때만 — globe 위치는 영속화하지 않음) const cb = onViewStateChangeRef.current; - if (cb && map) { + 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() }); } @@ -254,5 +244,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/useProjectionToggle.ts b/apps/web/src/widgets/map3d/hooks/useProjectionToggle.ts index 2b92733..a4f0cd3 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], ); @@ -176,45 +173,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 +202,9 @@ export function useProjectionToggle( const shouldSwitchProjection = currentProjection !== next; if (projection === 'globe') { - disposeMercatorOverlays(); - clearGlobeNativeLayers(); + quietMercatorOverlays(); } else { - disposeGlobeDeckLayer(); - clearGlobeNativeLayers(); + quietGlobeDeckLayer(); } try { @@ -248,6 +212,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 +238,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 +249,8 @@ export function useProjectionToggle( } catch { // ignore } - if (!map.getLayer(layerId) && !cancelled && retries < maxRetries) { - retries += 1; - window.requestAnimationFrame(() => syncProjectionAndDeck()); - return; - } } } else { - disposeGlobeDeckLayer(); ensureMercatorOverlay(); } @@ -324,7 +285,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/lib/geometry.ts b/apps/web/src/widgets/map3d/lib/geometry.ts index 6e8f5eb..c1c26a6 100644 --- a/apps/web/src/widgets/map3d/lib/geometry.ts +++ b/apps/web/src/widgets/map3d/lib/geometry.ts @@ -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; } From 95d9ea8aef4193138fb475de9a83e66c9cd00b9e Mon Sep 17 00:00:00 2001 From: htlee Date: Mon, 16 Feb 2026 13:34:42 +0900 Subject: [PATCH 06/16] =?UTF-8?q?fix(map):=20=EB=9D=BC=EB=B2=A8=20?= =?UTF-8?q?=EC=82=AC=EB=9D=BC=EC=A7=90=20+=20easing=20=EA=B2=BD=EA=B3=A0?= =?UTF-8?q?=20+=20vertex=20=EA=B2=BD=EA=B3=A0=20=EC=88=98=EC=A0=95?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - guardedSetVisibility 도입: 현재 값과 동일하면 setLayoutProperty 호출 생략하여 style._changed 트리거 방지 → symbol 재배치로 인한 text-allow-overlap:false 라벨 사라짐 현상 해결 - useGlobeShips 기존 레이어 else 블록의 중복 expression 재설정 제거 (data-driven 표현식은 addLayer 시 1회 설정으로 충분) - _render 래퍼에서 globe scrollZoom easing 경고 억제 - fleet-circles-ml-fill 레이어 완전 제거 (vertex 65535 초과 원인) Co-Authored-By: Claude Opus 4.6 --- .../map3d/hooks/useGlobeInteraction.ts | 8 +- .../widgets/map3d/hooks/useGlobeOverlays.ts | 123 ++---------------- .../src/widgets/map3d/hooks/useGlobeShips.ts | 119 +++-------------- .../web/src/widgets/map3d/hooks/useMapInit.ts | 13 +- .../map3d/hooks/useProjectionToggle.ts | 1 - .../web/src/widgets/map3d/lib/layerHelpers.ts | 18 ++- 6 files changed, 64 insertions(+), 218 deletions(-) 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 2803246..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,29 +280,6 @@ export function useGlobeOverlays( }), }; - // fill 폴리곤은 globe 테셀레이션으로 vertex 수가 급격히 증가하므로 - // 스텝 수를 줄이고 (24), 반경을 500nm으로 상한 설정 - const MAX_FILL_RADIUS_M = 500 * 1852; - const fcFill: GeoJSON.FeatureCollection = { - type: 'FeatureCollection', - features: (fleetCircles || []).map((c) => { - const ring = circleRingLngLat(c.center, Math.min(c.radiusNm * 1852, MAX_FILL_RADIUS_M), 24); - 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); @@ -336,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( @@ -391,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(); @@ -418,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 = () => { @@ -506,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); @@ -596,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 0b6a08e..5d55bdb 100644 --- a/apps/web/src/widgets/map3d/hooks/useGlobeShips.ts +++ b/apps/web/src/widgets/map3d/hooks/useGlobeShips.ts @@ -25,6 +25,7 @@ import { ensureFallbackShipImage, } from '../lib/globeShipIcon'; import { clampNumber } from '../lib/geometry'; +import { guardedSetVisibility } from '../lib/layerHelpers'; export function useGlobeShips( mapRef: MutableRefObject, @@ -273,11 +274,10 @@ export function useGlobeShips( const labelId = 'ships-globe-label'; // 레이어를 제거하지 않고 visibility만 'none'으로 설정 + // guardedSetVisibility로 현재 값과 동일하면 호출 생략 (style._changed 방지) const hide = () => { for (const id of [labelId, symbolId, outlineId, haloId]) { - try { - if (map.getLayer(id)) map.setLayoutProperty(id, 'visibility', 'none'); - } catch { /* ignore */ } + guardedSetVisibility(map, id, 'none'); } }; @@ -296,15 +296,19 @@ export function useGlobeShips( } // 빠른 visibility 토글 — projectionBusy 중에도 실행 - // 이미 생성된 레이어의 표시 상태만 즉시 전환하여 projection 전환 체감 속도 개선 - const visibility = projection === 'globe' ? 'visible' : 'none'; - const labelVisibility = projection === 'globe' && overlays.shipLabels ? 'visible' : 'none'; + // 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)) { - for (const id of [haloId, outlineId, symbolId]) { - try { map.setLayoutProperty(id, 'visibility', visibility); } catch { /* ignore */ } + 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); } - try { map.setLayoutProperty(labelId, 'visibility', labelVisibility); } catch { /* ignore */ } - if (projection === 'globe') kickRepaint(map); + guardedSetVisibility(map, labelId, labelVisibility); } // 데이터 업데이트는 projectionBusy 중에는 차단 @@ -374,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 { @@ -448,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 { @@ -538,29 +487,8 @@ 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 labelFilter = [ 'all', @@ -611,15 +539,8 @@ export function useGlobeShips( } catch (e) { console.warn('Ship label layer add failed:', e); } - } else { - try { - map.setLayoutProperty(labelId, 'visibility', labelVisibility); - map.setFilter(labelId, labelFilter as never); - map.setLayoutProperty(labelId, 'text-field', ['get', 'labelName'] as never); - } catch { - // ignore - } } + // label: filter/text-field are static — visibility handled by fast toggle // 레이어가 준비되었음을 알림 (projection과 무관 — 토글 버튼 활성화용) onGlobeShipsReady?.(true); @@ -658,9 +579,7 @@ export function useGlobeShips( const hideHover = () => { for (const id of [symbolId, outlineId, haloId]) { - try { - if (map.getLayer(id)) map.setLayoutProperty(id, 'visibility', 'none'); - } catch { /* ignore */ } + guardedSetVisibility(map, id, 'none'); } }; diff --git a/apps/web/src/widgets/map3d/hooks/useMapInit.ts b/apps/web/src/widgets/map3d/hooks/useMapInit.ts index a9a325e..d14701e 100644 --- a/apps/web/src/widgets/map3d/hooks/useMapInit.ts +++ b/apps/web/src/widgets/map3d/hooks/useMapInit.ts @@ -93,11 +93,19 @@ export function useMapInit( map.addControl(new maplibregl.NavigationControl({ showZoom: true, showCompass: true }), 'top-left'); map.addControl(new maplibregl.ScaleControl({ maxWidth: 120, unit: 'metric' }), 'bottom-left'); - // MapLibre 내부 placement TypeError 방어 + // 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' && (args[0] as string).includes('Easing around a point')) return; + origWarn.apply(console, args as [unknown, ...unknown[]]); + }; try { origRender.call(this, arg); } catch (e) { @@ -105,6 +113,9 @@ export function useMapInit( return; } throw e; + } finally { + // eslint-disable-next-line no-console + console.warn = origWarn; } }; } diff --git a/apps/web/src/widgets/map3d/hooks/useProjectionToggle.ts b/apps/web/src/widgets/map3d/hooks/useProjectionToggle.ts index a4f0cd3..9bc33de 100644 --- a/apps/web/src/widgets/map3d/hooks/useProjectionToggle.ts +++ b/apps/web/src/widgets/map3d/hooks/useProjectionToggle.ts @@ -111,7 +111,6 @@ export function useProjectionToggle( 'pair-lines-ml', 'fc-lines-ml', 'pair-range-ml', - 'fleet-circles-ml-fill', 'fleet-circles-ml', ]; diff --git a/apps/web/src/widgets/map3d/lib/layerHelpers.ts b/apps/web/src/widgets/map3d/lib/layerHelpers.ts index a49ae3a..f5277a2 100644 --- a/apps/web/src/widgets/map3d/lib/layerHelpers.ts +++ b/apps/web/src/widgets/map3d/lib/layerHelpers.ts @@ -28,7 +28,6 @@ export function removeSourceIfExists(map: maplibregl.Map, sourceId: string) { const GLOBE_NATIVE_LAYER_IDS = [ 'pair-lines-ml', 'fc-lines-ml', - 'fleet-circles-ml-fill', 'fleet-circles-ml', 'pair-range-ml', 'subcables-hitarea', @@ -44,7 +43,6 @@ const GLOBE_NATIVE_SOURCE_IDS = [ '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', @@ -96,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[], From 99d714582b2b03d30df6661a2dda013f81514c0d Mon Sep 17 00:00:00 2001 From: htlee Date: Mon, 16 Feb 2026 13:38:37 +0900 Subject: [PATCH 07/16] =?UTF-8?q?fix(map):=20globe=20=EB=AA=A8=EB=93=9C=20?= =?UTF-8?q?zones-fill=20=EC=88=A8=EA=B9=80=20+=20=EB=9D=BC=EB=B2=A8=20?= =?UTF-8?q?=EA=B0=80=EB=93=9C?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - globe tessellation에서 수역 fill polygon vertex 65535 초과 (해안선 2100 vertex → globe에서 108890+로 폭증) → 노란 막대 - globe 모드에서 zones-fill visibility: none으로 설정 - guardedSetVisibility 적용으로 수역 라벨 사라짐 방지 Co-Authored-By: Claude Opus 4.6 --- .../src/widgets/map3d/hooks/useZonesLayer.ts | 26 +++++++------------ 1 file changed, 9 insertions(+), 17 deletions(-) diff --git a/apps/web/src/widgets/map3d/hooks/useZonesLayer.ts b/apps/web/src/widgets/map3d/hooks/useZonesLayer.ts index e1f19fd..19b143e 100644 --- a/apps/web/src/widgets/map3d/hooks/useZonesLayer.ts +++ b/apps/web/src/widgets/map3d/hooks/useZonesLayer.ts @@ -10,6 +10,7 @@ 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'; export function useZonesLayer( mapRef: MutableRefObject, @@ -48,22 +49,13 @@ export function useZonesLayer( 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 - } + const visibility: 'visible' | 'none' = overlays.zones ? 'visible' : 'none'; + // globe 모드에서 fill polygon은 tessellation으로 vertex 65535 초과 → 숨김 + // (해안선 디테일 2100+ vertex가 globe에서 100,000+로 폭증하여 노란 막대 아티팩트 발생) + const fillVisibility: 'visible' | 'none' = projection === 'globe' ? 'none' : visibility; + guardedSetVisibility(map, fillId, fillVisibility); + guardedSetVisibility(map, lineId, visibility); + guardedSetVisibility(map, labelId, visibility); if (!zones) return; if (!map.isStyleLoaded()) return; @@ -160,7 +152,7 @@ export function useZonesLayer( ] as unknown as number) : 0.12, }, - layout: { visibility }, + layout: { visibility: fillVisibility }, } as unknown as LayerSpecification, before, ); From 7bec1ae86d99719d5976b55a1240a685b6fd21e5 Mon Sep 17 00:00:00 2001 From: htlee Date: Mon, 16 Feb 2026 13:44:26 +0900 Subject: [PATCH 08/16] =?UTF-8?q?fix(map):=20globe=20=EC=88=98=EC=97=AD=20?= =?UTF-8?q?line=20vertex=20=EC=B4=88=EA=B3=BC=20=ED=95=B4=EA=B2=B0?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit zones-line도 globe tessellation에서 73,300+ vertex로 폭증. globe 모드에서 수역 소스 데이터를 ring당 60점으로 서브샘플링. 원본 2100+ vertex → ~240 vertex → globe tessellation 후 65535 이내. mercator 모드에서는 원본 데이터 유지. Co-Authored-By: Claude Opus 4.6 --- .../src/widgets/map3d/hooks/useZonesLayer.ts | 33 +++++++++++++++++-- 1 file changed, 31 insertions(+), 2 deletions(-) diff --git a/apps/web/src/widgets/map3d/hooks/useZonesLayer.ts b/apps/web/src/widgets/map3d/hooks/useZonesLayer.ts index 19b143e..6215356 100644 --- a/apps/web/src/widgets/map3d/hooks/useZonesLayer.ts +++ b/apps/web/src/widgets/map3d/hooks/useZonesLayer.ts @@ -12,6 +12,33 @@ 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, projectionBusyRef: MutableRefObject, @@ -61,11 +88,13 @@ export function useZonesLayer( if (!map.isStyleLoaded()) return; try { + // globe: 서브샘플링된 데이터로 vertex 폭증 방지, mercator: 원본 데이터 + const sourceData = projection === 'globe' ? simplifyZonesForGlobe(zones) : zones; const existing = map.getSource(srcId) as GeoJSONSource | undefined; if (existing) { - existing.setData(zones); + existing.setData(sourceData); } else { - map.addSource(srcId, { type: 'geojson', data: zones } as GeoJSONSourceSpecification); + map.addSource(srcId, { type: 'geojson', data: sourceData } as GeoJSONSourceSpecification); } const style = map.getStyle(); From d5700ba5873baec270d649db934029909e607004 Mon Sep 17 00:00:00 2001 From: htlee Date: Mon, 16 Feb 2026 13:59:21 +0900 Subject: [PATCH 09/16] =?UTF-8?q?fix(map):=20zone=20=EA=B0=84=EC=86=8C?= =?UTF-8?q?=ED=99=94=EB=A5=BC=20projectionBusy=20=EC=95=9E=EC=9C=BC?= =?UTF-8?q?=EB=A1=9C=20=EC=9D=B4=EB=8F=99?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 소스 데이터 간소화가 projectionBusy 가드 뒤에 있어서 globe 전환 시 원본 데이터(2100+ vertex)로 tessellation 진행 → 73,000+ vertex 폭증. setData를 가드 앞으로 이동하고 useMemo로 간소화 데이터 캐싱. Co-Authored-By: Claude Opus 4.6 --- .../src/widgets/map3d/hooks/useZonesLayer.ts | 36 ++++++++++++------- 1 file changed, 24 insertions(+), 12 deletions(-) diff --git a/apps/web/src/widgets/map3d/hooks/useZonesLayer.ts b/apps/web/src/widgets/map3d/hooks/useZonesLayer.ts index 6215356..ea1f29e 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, @@ -54,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; @@ -75,26 +81,32 @@ export function useZonesLayer( zoneLabelExpr.push(['coalesce', ['get', 'zoneName'], ['get', 'zoneLabel'], ['get', 'NAME'], '수역']); const ensure = () => { - if (projectionBusyRef.current) return; + // 소스 데이터 간소화 — 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'; - // globe 모드에서 fill polygon은 tessellation으로 vertex 65535 초과 → 숨김 - // (해안선 디테일 2100+ vertex가 globe에서 100,000+로 폭증하여 노란 막대 아티팩트 발생) const fillVisibility: 'visible' | 'none' = projection === 'globe' ? 'none' : visibility; guardedSetVisibility(map, fillId, fillVisibility); guardedSetVisibility(map, lineId, visibility); guardedSetVisibility(map, labelId, visibility); + if (projectionBusyRef.current) return; if (!zones) return; if (!map.isStyleLoaded()) return; try { - // globe: 서브샘플링된 데이터로 vertex 폭증 방지, mercator: 원본 데이터 - const sourceData = projection === 'globe' ? simplifyZonesForGlobe(zones) : zones; - const existing = map.getSource(srcId) as GeoJSONSource | undefined; - if (existing) { - existing.setData(sourceData); - } else { - map.addSource(srcId, { type: 'geojson', data: sourceData } 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(); @@ -247,5 +259,5 @@ export function useZonesLayer( return () => { stop(); }; - }, [zones, overlays.zones, projection, baseMap, hoveredZoneId, mapSyncEpoch, reorderGlobeFeatureLayers]); + }, [zones, simplifiedZones, overlays.zones, projection, baseMap, hoveredZoneId, mapSyncEpoch, reorderGlobeFeatureLayers]); } From b022e4bc36862ac8ca7017222dc1b70acdc58d74 Mon Sep 17 00:00:00 2001 From: htlee Date: Mon, 16 Feb 2026 15:15:31 +0900 Subject: [PATCH 10/16] =?UTF-8?q?perf(map):=20=EC=A4=8C=20=EA=B8=B0?= =?UTF-8?q?=EB=B0=98=20LOD=20+=20=EC=8B=AC=ED=95=B4=20=EB=93=B1=EC=8B=AC?= =?UTF-8?q?=EC=84=A0=20=EC=A0=9C=EA=B1=B0?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - applyLandLayerLOD: 베이스맵 육지 레이어에 minzoom 적용 (landcover z9, transportation z8, building z14 등) - 수심 3-tier LOD: coarse(z3-7), medium(z7-9), detail(z9+) - shallowFilter: depth >= -2000, 심해 feature GPU 미전달 - applyDepthGradient ascending order 에러 수정 - vertex 경고 passthrough (디버깅용 유지) Co-Authored-By: Claude Opus 4.6 --- .../widgets/map3d/hooks/useBaseMapToggle.ts | 2 +- .../web/src/widgets/map3d/hooks/useMapInit.ts | 14 +- .../map3d/hooks/useMapStyleSettings.ts | 14 +- .../src/widgets/map3d/layers/bathymetry.ts | 209 ++++++++++++------ 4 files changed, 167 insertions(+), 72 deletions(-) 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/useMapInit.ts b/apps/web/src/widgets/map3d/hooks/useMapInit.ts index d14701e..17261db 100644 --- a/apps/web/src/widgets/map3d/hooks/useMapInit.ts +++ b/apps/web/src/widgets/map3d/hooks/useMapInit.ts @@ -103,7 +103,15 @@ export function useMapInit( // globe 모드에서 scrollZoom의 easeTo around 경고 억제 // eslint-disable-next-line no-console console.warn = function (...args: unknown[]) { - if (typeof args[0] === 'string' && (args[0] as string).includes('Easing around a point')) return; + 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 { @@ -177,7 +185,7 @@ export function useMapInit( } if (!showSeamarkRef.current) return; try { - ensureSeamarkOverlay(map!, 'bathymetry-lines'); + ensureSeamarkOverlay(map!, 'bathymetry-lines-coarse'); } catch { // ignore } @@ -203,7 +211,7 @@ export function useMapInit( map.once('load', () => { if (showSeamarkRef.current) { try { - ensureSeamarkOverlay(map!, 'bathymetry-lines'); + ensureSeamarkOverlay(map!, 'bathymetry-lines-coarse'); } catch { // ignore } diff --git a/apps/web/src/widgets/map3d/hooks/useMapStyleSettings.ts b/apps/web/src/widgets/map3d/hooks/useMapStyleSettings.ts index 00feb3f..1ea7049 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'] @@ -105,14 +105,16 @@ 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)); + } 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/layers/bathymetry.ts b/apps/web/src/widgets/map3d/layers/bathymetry.ts index 4c4089b..da12e57 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,19 +74,11 @@ 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 + // 수심 색상: -2000m에서 절단 — 심해는 베이스 수색과 동일하게 처리 const bathyFillColor = [ 'interpolate', ['linear'], depth, - -11000, - '#00040b', - -8000, - '#010610', - -6000, - '#020816', - -4000, - '#030c1c', -2000, '#041022', -1000, @@ -64,6 +99,17 @@ export function injectOceanBathymetryLayers(style: StyleSpecification, maptilerK '#2097a6', ] as const; + // depth >= -2000 필터: -2000m보다 깊은 등심선은 GPU에 전달하지 않음 + const shallowFilter = ['>=', depth, -2000] as unknown[]; + + // --- Depth tiers for zoom-based LOD --- + const DEPTHS_COARSE = [-1000, -2000]; + const DEPTHS_MEDIUM = [-100, -500, -1000, -2000]; + const DEPTHS_DETAIL = [-50, -100, -200, -500, -1000, -2000]; + const depthIn = (depths: number[]) => + ['all', shallowFilter, ['in', depth, ['literal', depths]]] as unknown[]; + + // === Fill (contour polygons) — 단일 레이어, shallowFilter만 적용 === const bathyFill: LayerSpecification = { id: 'bathymetry-fill', type: 'fill', @@ -71,104 +117,140 @@ export function injectOceanBathymetryLayers(style: StyleSpecification, maptilerK 'source-layer': 'contour', minzoom: 3, maxzoom: 24, + filter: shallowFilter as unknown as unknown[], paint: { 'fill-color': bathyFillColor, 'fill-opacity': ['interpolate', ['linear'], ['zoom'], 0, 0.9, 5, 0.88, 8, 0.84, 10, 0.78], }, } 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+: 전체 shallow 등심선 경계 + const bathyBorders: LayerSpecification = { id: 'bathymetry-borders', type: 'line', source: oceanSourceId, 'source-layer': 'contour', - minzoom: 5, // fill은 3부터, borders는 5부터 + minzoom: 7, maxzoom: 24, + filter: shallowFilter as unknown as unknown[], 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, 2000m 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, 100, 200, 500, 1000, 2000m (풀 디테일) + 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, 500, 1000, 2000m 라벨 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)); @@ -298,6 +382,7 @@ export async function resolveInitialMapStyle(signal: AbortSignal): Promise Date: Mon, 16 Feb 2026 15:15:45 +0900 Subject: [PATCH 11/16] =?UTF-8?q?fix(map):=20globe=20=EB=AA=A8=EB=93=9C=20?= =?UTF-8?q?=EC=88=98=EC=97=AD=20fill/text=20=EB=B3=B5=EA=B5=AC?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-Authored-By: Claude Opus 4.6 --- apps/web/src/widgets/map3d/hooks/useZonesLayer.ts | 5 ++--- 1 file changed, 2 insertions(+), 3 deletions(-) diff --git a/apps/web/src/widgets/map3d/hooks/useZonesLayer.ts b/apps/web/src/widgets/map3d/hooks/useZonesLayer.ts index ea1f29e..645f2c1 100644 --- a/apps/web/src/widgets/map3d/hooks/useZonesLayer.ts +++ b/apps/web/src/widgets/map3d/hooks/useZonesLayer.ts @@ -93,8 +93,7 @@ export function useZonesLayer( } const visibility: 'visible' | 'none' = overlays.zones ? 'visible' : 'none'; - const fillVisibility: 'visible' | 'none' = projection === 'globe' ? 'none' : visibility; - guardedSetVisibility(map, fillId, fillVisibility); + guardedSetVisibility(map, fillId, visibility); guardedSetVisibility(map, lineId, visibility); guardedSetVisibility(map, labelId, visibility); @@ -193,7 +192,7 @@ export function useZonesLayer( ] as unknown as number) : 0.12, }, - layout: { visibility: fillVisibility }, + layout: { visibility }, } as unknown as LayerSpecification, before, ); From 2095503e50fd099f4a25d06459bc491fd6b35a22 Mon Sep 17 00:00:00 2001 From: htlee Date: Mon, 16 Feb 2026 15:21:46 +0900 Subject: [PATCH 12/16] =?UTF-8?q?fix(map):=20=EC=8B=AC=ED=95=B4=20?= =?UTF-8?q?=EB=93=B1=EC=8B=AC=EC=84=A0=20=EB=B3=B5=EC=9B=90=20+=20?= =?UTF-8?q?=EC=A0=84=EC=B2=B4=20depth=20LOD?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - shallowFilter 제거: 전체 depth 범위 렌더링 복원 - bathyFillColor: -8000m, -4000m 색상 stop 복원 - DEPTHS_MEDIUM: -4000m 추가 (z7-9) - DEPTHS_DETAIL: -4000m, -8000m 추가 (z9+) - 줌 기반 LOD가 vertex 제어 담당 (depth 필터 불필요) Co-Authored-By: Claude Opus 4.6 --- .../src/widgets/map3d/layers/bathymetry.ts | 28 +++++++++---------- 1 file changed, 14 insertions(+), 14 deletions(-) diff --git a/apps/web/src/widgets/map3d/layers/bathymetry.ts b/apps/web/src/widgets/map3d/layers/bathymetry.ts index da12e57..86074f3 100644 --- a/apps/web/src/widgets/map3d/layers/bathymetry.ts +++ b/apps/web/src/widgets/map3d/layers/bathymetry.ts @@ -74,11 +74,15 @@ 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[]; - // 수심 색상: -2000m에서 절단 — 심해는 베이스 수색과 동일하게 처리 + // 수심 색상: 전체 범위 (-8000m ~ 0m) const bathyFillColor = [ 'interpolate', ['linear'], depth, + -8000, + '#010610', + -4000, + '#030c1c', -2000, '#041022', -1000, @@ -99,17 +103,15 @@ export function injectOceanBathymetryLayers(style: StyleSpecification, maptilerK '#2097a6', ] as const; - // depth >= -2000 필터: -2000m보다 깊은 등심선은 GPU에 전달하지 않음 - const shallowFilter = ['>=', depth, -2000] as unknown[]; - // --- Depth tiers for zoom-based LOD --- + // 줌 기반 LOD로 vertex 제어 — 줌아웃에선 주요 등심선만, 줌인에서 점진적 디테일 const DEPTHS_COARSE = [-1000, -2000]; - const DEPTHS_MEDIUM = [-100, -500, -1000, -2000]; - const DEPTHS_DETAIL = [-50, -100, -200, -500, -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[]) => - ['all', shallowFilter, ['in', depth, ['literal', depths]]] as unknown[]; + ['in', depth, ['literal', depths]] as unknown[]; - // === Fill (contour polygons) — 단일 레이어, shallowFilter만 적용 === + // === Fill (contour polygons) — 전체 depth, 줌에 따라 자연스럽게 표시 === const bathyFill: LayerSpecification = { id: 'bathymetry-fill', type: 'fill', @@ -117,7 +119,6 @@ export function injectOceanBathymetryLayers(style: StyleSpecification, maptilerK 'source-layer': 'contour', minzoom: 3, maxzoom: 24, - filter: shallowFilter as unknown as unknown[], paint: { 'fill-color': bathyFillColor, 'fill-opacity': ['interpolate', ['linear'], ['zoom'], 0, 0.9, 5, 0.88, 8, 0.84, 10, 0.78], @@ -142,7 +143,7 @@ export function injectOceanBathymetryLayers(style: StyleSpecification, maptilerK }, } as unknown as LayerSpecification; - // z7+: 전체 shallow 등심선 경계 + // z7+: 전체 등심선 경계 const bathyBorders: LayerSpecification = { id: 'bathymetry-borders', type: 'line', @@ -150,7 +151,6 @@ export function injectOceanBathymetryLayers(style: StyleSpecification, maptilerK 'source-layer': 'contour', minzoom: 7, maxzoom: 24, - filter: shallowFilter as unknown as unknown[], paint: { 'line-color': 'rgba(255,255,255,0.06)', 'line-opacity': ['interpolate', ['linear'], ['zoom'], 7, 0.18, 12, 0.22], @@ -177,7 +177,7 @@ export function injectOceanBathymetryLayers(style: StyleSpecification, maptilerK }, } as unknown as LayerSpecification; - // z7-z9: 100, 500, 1000, 2000m + // z7-z9: 100, 500, 1000, 2000, 4000m const bathyLinesMajor: LayerSpecification = { id: 'bathymetry-lines-major', type: 'line', @@ -194,7 +194,7 @@ export function injectOceanBathymetryLayers(style: StyleSpecification, maptilerK }, } as unknown as LayerSpecification; - // z9+: 50, 100, 200, 500, 1000, 2000m (풀 디테일) + // z9+: 50~8000m (풀 디테일) const bathyLinesDetail: LayerSpecification = { id: 'bathymetry-lines-detail', type: 'line', @@ -238,7 +238,7 @@ export function injectOceanBathymetryLayers(style: StyleSpecification, maptilerK }, } as unknown as LayerSpecification; - // z9+: 100, 500, 1000, 2000m 라벨 + // z9+: 100~4000m 라벨 const bathyLabels: LayerSpecification = { id: 'bathymetry-labels', type: 'symbol', From 3a001ca9b62a2bd7294d6999d83a1f0429a4bbb3 Mon Sep 17 00:00:00 2001 From: htlee Date: Mon, 16 Feb 2026 15:28:23 +0900 Subject: [PATCH 13/16] =?UTF-8?q?fix(map):=20fill=203-tier=20LOD=EB=A1=9C?= =?UTF-8?q?=20=ED=83=80=EC=9D=BC=20seam=20=ED=95=B4=EA=B2=B0?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 심해 fill 폴리곤이 globe 타일 경계에서 seam 아티팩트 발생 - bathymetry-fill: z3-7 (depth >= -2000, 천해만) - bathymetry-fill-medium: z7-9 (depth >= -4000) - bathymetry-fill-deep: z9+ (전체 depth) - applyDepthGradient: 3개 fill 레이어 모두 적용 Co-Authored-By: Claude Opus 4.6 --- .../map3d/hooks/useMapStyleSettings.ts | 12 ++--- .../src/widgets/map3d/layers/bathymetry.ts | 45 ++++++++++++++++--- 2 files changed, 46 insertions(+), 11 deletions(-) diff --git a/apps/web/src/widgets/map3d/hooks/useMapStyleSettings.ts b/apps/web/src/widgets/map3d/hooks/useMapStyleSettings.ts index 1ea7049..01b4282 100644 --- a/apps/web/src/widgets/map3d/hooks/useMapStyleSettings.ts +++ b/apps/web/src/widgets/map3d/hooks/useMapStyleSettings.ts @@ -102,7 +102,6 @@ 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; @@ -115,10 +114,13 @@ function applyDepthGradient(map: maplibregl.Map, stops: DepthColorStop[]) { if (shallowest.depth < 0) { expr.push(0, lightenHex(shallowest.color, 1.8)); } - try { - map.setPaintProperty('bathymetry-fill', 'fill-color', expr as never); - } catch { - // ignore + for (const layerId of ['bathymetry-fill', 'bathymetry-fill-medium', 'bathymetry-fill-deep']) { + if (!map.getLayer(layerId)) continue; + try { + map.setPaintProperty(layerId, 'fill-color', expr as never); + } catch { + // ignore + } } } diff --git a/apps/web/src/widgets/map3d/layers/bathymetry.ts b/apps/web/src/widgets/map3d/layers/bathymetry.ts index 86074f3..a1cb02e 100644 --- a/apps/web/src/widgets/map3d/layers/bathymetry.ts +++ b/apps/web/src/widgets/map3d/layers/bathymetry.ts @@ -10,7 +10,9 @@ export const SHALLOW_WATER_FILL_DEFAULT = '#14606e'; export const SHALLOW_WATER_LINE_DEFAULT = '#114f5c'; const BATHY_ZOOM_RANGES: BathyZoomRange[] = [ - { id: 'bathymetry-fill', mercator: [3, 24], globe: [3, 24] }, + { id: 'bathymetry-fill', mercator: [3, 7], globe: [3, 7] }, + { id: 'bathymetry-fill-medium', mercator: [7, 9], globe: [7, 9] }, + { id: 'bathymetry-fill-deep', mercator: [9, 24], globe: [9, 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] }, @@ -111,18 +113,47 @@ export function injectOceanBathymetryLayers(style: StyleSpecification, maptilerK const depthIn = (depths: number[]) => ['in', depth, ['literal', depths]] as unknown[]; - // === Fill (contour polygons) — 전체 depth, 줌에 따라 자연스럽게 표시 === + // === Fill (contour polygons) — 3-tier LOD === + // 심해 폴리곤이 여러 벡터 타일에 걸칠 때 globe tessellation 타일 경계에서 + // seam 아티팩트 발생 → 줌아웃에서는 shallow만, 줌인에서 점진적으로 심해 포함 + const bathyFillPaint = { + 'fill-color': bathyFillColor, + 'fill-opacity': ['interpolate', ['linear'], ['zoom'], 0, 0.9, 5, 0.88, 8, 0.84, 10, 0.78], + }; + + // z3-7: depth >= -2000 (천해만 — 타일 seam 방지) const bathyFill: LayerSpecification = { id: 'bathymetry-fill', type: 'fill', source: oceanSourceId, 'source-layer': 'contour', minzoom: 3, + maxzoom: 7, + filter: ['>=', depth, -2000] as unknown as unknown[], + paint: bathyFillPaint, + } as unknown as LayerSpecification; + + // z7-9: depth >= -4000 (중심해 포함) + const bathyFillMedium: LayerSpecification = { + id: 'bathymetry-fill-medium', + type: 'fill', + source: oceanSourceId, + 'source-layer': 'contour', + minzoom: 7, + maxzoom: 9, + filter: ['>=', depth, -4000] as unknown as unknown[], + paint: bathyFillPaint, + } as unknown as LayerSpecification; + + // z9+: 전체 depth (풀 디테일 — 뷰포트가 작아 타일 seam 무관) + const bathyFillDeep: LayerSpecification = { + id: 'bathymetry-fill-deep', + type: 'fill', + source: oceanSourceId, + 'source-layer': 'contour', + minzoom: 9, maxzoom: 24, - paint: { - 'fill-color': bathyFillColor, - 'fill-opacity': ['interpolate', ['linear'], ['zoom'], 0, 0.9, 5, 0.88, 8, 0.84, 10, 0.78], - }, + paint: bathyFillPaint, } as unknown as LayerSpecification; // === Borders (contour polygon edges) — 2-tier LOD === @@ -326,6 +357,8 @@ export function injectOceanBathymetryLayers(style: StyleSpecification, maptilerK const toInsert = [ bathyFill, + bathyFillMedium, + bathyFillDeep, bathyBordersMajor, bathyBorders, bathyLinesCoarse, From 1fd9f3da825cafa0636cfccd5a6aae30b45eab9c Mon Sep 17 00:00:00 2001 From: htlee Date: Mon, 16 Feb 2026 15:34:49 +0900 Subject: [PATCH 14/16] =?UTF-8?q?fix(map):=20fill=20=EB=8B=A8=EC=9D=BC?= =?UTF-8?q?=ED=99=94=20+=20globe=20=EB=B0=B0=EA=B2=BD=EC=83=89=20=EC=8B=AC?= =?UTF-8?q?=ED=95=B4=EC=83=89=20=ED=86=B5=EC=9D=BC?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - fill 3-tier 제거 → 단일 레이어(전체 depth) 복원 - setSky: sky/horizon/fog를 심해색(#010610)으로 설정 - 캔버스/map-area 배경: #010610 (타일 gap seam 비가시화) - 타일 경계 gap으로 배경이 비칠 때 색상 차이를 제거 Co-Authored-By: Claude Opus 4.6 --- apps/web/src/app/styles.css | 1 + .../web/src/widgets/map3d/hooks/useMapInit.ts | 20 +++++++++ .../map3d/hooks/useMapStyleSettings.ts | 12 +++-- .../src/widgets/map3d/layers/bathymetry.ts | 45 +++---------------- 4 files changed, 32 insertions(+), 46 deletions(-) diff --git a/apps/web/src/app/styles.css b/apps/web/src/app/styles.css index 462325f..c17d8d1 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/widgets/map3d/hooks/useMapInit.ts b/apps/web/src/widgets/map3d/hooks/useMapInit.ts index 17261db..0a276fd 100644 --- a/apps/web/src/widgets/map3d/hooks/useMapInit.ts +++ b/apps/web/src/widgets/map3d/hooks/useMapInit.ts @@ -209,6 +209,26 @@ export function useMapInit( }, 60_000); map.once('load', () => { + // Globe 배경(타일 밖)을 심해 색상과 맞춰 타일 경계 seam을 비가시화 + try { + map!.setSky({ + 'sky-color': '#010610', + 'horizon-color': '#010610', + 'fog-color': '#010610', + 'fog-ground-blend': 1, + 'sky-horizon-blend': 0, + 'atmosphere-blend': 0, + }); + } catch { + // ignore + } + // 캔버스 배경도 심해색으로 통일 + try { + map!.getCanvas().style.background = '#010610'; + } catch { + // ignore + } + if (showSeamarkRef.current) { try { ensureSeamarkOverlay(map!, 'bathymetry-lines-coarse'); diff --git a/apps/web/src/widgets/map3d/hooks/useMapStyleSettings.ts b/apps/web/src/widgets/map3d/hooks/useMapStyleSettings.ts index 01b4282..9edefc9 100644 --- a/apps/web/src/widgets/map3d/hooks/useMapStyleSettings.ts +++ b/apps/web/src/widgets/map3d/hooks/useMapStyleSettings.ts @@ -114,13 +114,11 @@ function applyDepthGradient(map: maplibregl.Map, stops: DepthColorStop[]) { if (shallowest.depth < 0) { expr.push(0, lightenHex(shallowest.color, 1.8)); } - for (const layerId of ['bathymetry-fill', 'bathymetry-fill-medium', 'bathymetry-fill-deep']) { - if (!map.getLayer(layerId)) continue; - try { - map.setPaintProperty(layerId, 'fill-color', expr as never); - } catch { - // ignore - } + if (!map.getLayer('bathymetry-fill')) return; + try { + map.setPaintProperty('bathymetry-fill', 'fill-color', expr as never); + } catch { + // ignore } } diff --git a/apps/web/src/widgets/map3d/layers/bathymetry.ts b/apps/web/src/widgets/map3d/layers/bathymetry.ts index a1cb02e..9f38f38 100644 --- a/apps/web/src/widgets/map3d/layers/bathymetry.ts +++ b/apps/web/src/widgets/map3d/layers/bathymetry.ts @@ -10,9 +10,7 @@ export const SHALLOW_WATER_FILL_DEFAULT = '#14606e'; export const SHALLOW_WATER_LINE_DEFAULT = '#114f5c'; const BATHY_ZOOM_RANGES: BathyZoomRange[] = [ - { id: 'bathymetry-fill', mercator: [3, 7], globe: [3, 7] }, - { id: 'bathymetry-fill-medium', mercator: [7, 9], globe: [7, 9] }, - { id: 'bathymetry-fill-deep', mercator: [9, 24], globe: [9, 24] }, + { id: 'bathymetry-fill', mercator: [3, 24], globe: [3, 24] }, { id: 'bathymetry-borders-major', mercator: [3, 7], globe: [3, 7] }, { id: 'bathymetry-borders', mercator: [7, 24], globe: [7, 24] }, { id: 'bathymetry-lines-coarse', mercator: [5, 7], globe: [5, 7] }, @@ -113,47 +111,18 @@ export function injectOceanBathymetryLayers(style: StyleSpecification, maptilerK const depthIn = (depths: number[]) => ['in', depth, ['literal', depths]] as unknown[]; - // === Fill (contour polygons) — 3-tier LOD === - // 심해 폴리곤이 여러 벡터 타일에 걸칠 때 globe tessellation 타일 경계에서 - // seam 아티팩트 발생 → 줌아웃에서는 shallow만, 줌인에서 점진적으로 심해 포함 - const bathyFillPaint = { - 'fill-color': bathyFillColor, - 'fill-opacity': ['interpolate', ['linear'], ['zoom'], 0, 0.9, 5, 0.88, 8, 0.84, 10, 0.78], - }; - - // z3-7: depth >= -2000 (천해만 — 타일 seam 방지) + // === Fill (contour polygons) — 단일 레이어, 전체 depth === const bathyFill: LayerSpecification = { id: 'bathymetry-fill', type: 'fill', source: oceanSourceId, 'source-layer': 'contour', minzoom: 3, - maxzoom: 7, - filter: ['>=', depth, -2000] as unknown as unknown[], - paint: bathyFillPaint, - } as unknown as LayerSpecification; - - // z7-9: depth >= -4000 (중심해 포함) - const bathyFillMedium: LayerSpecification = { - id: 'bathymetry-fill-medium', - type: 'fill', - source: oceanSourceId, - 'source-layer': 'contour', - minzoom: 7, - maxzoom: 9, - filter: ['>=', depth, -4000] as unknown as unknown[], - paint: bathyFillPaint, - } as unknown as LayerSpecification; - - // z9+: 전체 depth (풀 디테일 — 뷰포트가 작아 타일 seam 무관) - const bathyFillDeep: LayerSpecification = { - id: 'bathymetry-fill-deep', - type: 'fill', - source: oceanSourceId, - 'source-layer': 'contour', - minzoom: 9, maxzoom: 24, - paint: bathyFillPaint, + paint: { + 'fill-color': bathyFillColor, + 'fill-opacity': ['interpolate', ['linear'], ['zoom'], 0, 0.9, 5, 0.88, 8, 0.84, 10, 0.78], + }, } as unknown as LayerSpecification; // === Borders (contour polygon edges) — 2-tier LOD === @@ -357,8 +326,6 @@ export function injectOceanBathymetryLayers(style: StyleSpecification, maptilerK const toInsert = [ bathyFill, - bathyFillMedium, - bathyFillDeep, bathyBordersMajor, bathyBorders, bathyLinesCoarse, From c03dee0ade30c364ca274fa80e61d8dfa74ac6c1 Mon Sep 17 00:00:00 2001 From: htlee Date: Mon, 16 Feb 2026 16:04:58 +0900 Subject: [PATCH 15/16] =?UTF-8?q?fix(map):=20globe=20=EB=85=B8=EB=9E=80?= =?UTF-8?q?=EB=B2=BD=20=ED=95=B4=EA=B2=B0=20+=20=EC=8B=AC=ED=95=B4?= =?UTF-8?q?=EC=83=89=20=EB=B3=B5=EA=B5=AC?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - deck-globe 렌더 파이프라인 비활성화로 노란벽 해소 - 베이스 수색 원복 (#14606e/#114f5c) - globe 필터 로직 제거 (불필요) Co-Authored-By: Claude Opus 4.6 --- .../web/src/widgets/map3d/hooks/useDeckLayers.ts | 16 ++++++++++++++++ apps/web/src/widgets/map3d/layers/bathymetry.ts | 1 + 2 files changed, 17 insertions(+) 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/layers/bathymetry.ts b/apps/web/src/widgets/map3d/layers/bathymetry.ts index 9f38f38..7d5a48a 100644 --- a/apps/web/src/widgets/map3d/layers/bathymetry.ts +++ b/apps/web/src/widgets/map3d/layers/bathymetry.ts @@ -357,6 +357,7 @@ export function applyBathymetryZoomProfile(map: maplibregl.Map, baseMap: BaseMap // ignore } } + } function applyKoreanLabels(style: StyleSpecification) { From fe5ec7100ba6c723d81ae1df84e961aeaa8cf281 Mon Sep 17 00:00:00 2001 From: htlee Date: Mon, 16 Feb 2026 16:24:52 +0900 Subject: [PATCH 16/16] =?UTF-8?q?docs:=20CLAUDE.md=20=EC=B5=9C=EC=8B=A0=20?= =?UTF-8?q?=EA=B5=AC=EC=A1=B0=20=EB=B0=98=EC=98=81?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-Authored-By: Claude Opus 4.6 --- CLAUDE.md | 54 +++++++++++++++++++++--------------------------------- 1 file changed, 21 insertions(+), 33 deletions(-) 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 |