From cc807bb5f6c4045940a059a520de473cb0fed6c4 Mon Sep 17 00:00:00 2001 From: htlee Date: Mon, 16 Feb 2026 12:12:43 +0900 Subject: [PATCH 01/17] =?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/17] =?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/17] =?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/17] =?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/17] =?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/17] =?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/17] =?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/17] =?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/17] =?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/17] =?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/17] =?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/17] =?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/17] =?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/17] =?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/17] =?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/17] =?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 | From 69775c90a232a1c748d03a5318f36dbbd3614533 Mon Sep 17 00:00:00 2001 From: htlee Date: Mon, 16 Feb 2026 18:19:01 +0900 Subject: [PATCH 17/17] =?UTF-8?q?feat(map):=20=ED=95=AD=EC=A0=81=EC=A1=B0?= =?UTF-8?q?=ED=9A=8C=20+=20SVG=20=EC=BA=90=EC=8B=9C=20+=20fitBounds?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 대상선박 우클릭 컨텍스트 메뉴로 항적 조회 (6h~5d) - Mercator: PathLayer(고정) + TripsLayer(애니메이션) + ScatterplotLayer(포인트) - Globe: MapLibre 네이티브 line + arrow + circle 레이어 - rAF 직접 overlay 조작으로 React 재렌더링 방지 - SVG 아이콘 data URL 캐시로 네트워크 재요청 방지 - 항적 조회 시 자동 fitBounds (전체 항적 뷰포트 맞춤) - API 프록시 /api/ais-target/:mmsi/track 엔드포인트 추가 Co-Authored-By: Claude Opus 4.6 --- apps/api/src/index.ts | 46 + apps/web/package.json | 1 + .../entities/vesselTrack/api/fetchTrack.ts | 32 + .../vesselTrack/lib/buildTrackGeoJson.ts | 115 +++ .../src/entities/vesselTrack/model/types.ts | 39 + .../web/src/pages/dashboard/DashboardPage.tsx | 32 +- apps/web/src/widgets/map3d/Map3D.tsx | 80 +- .../map3d/components/VesselContextMenu.tsx | 135 +++ .../src/widgets/map3d/hooks/useDeckLayers.ts | 9 +- .../map3d/hooks/useMapStyleSettings.ts | 1 + .../map3d/hooks/useProjectionToggle.ts | 5 + .../widgets/map3d/hooks/useSubcablesLayer.ts | 65 +- .../map3d/hooks/useVesselTrackLayer.ts | 395 ++++++++ .../web/src/widgets/map3d/lib/layerHelpers.ts | 7 + .../src/widgets/map3d/lib/shipIconCache.ts | 30 + apps/web/src/widgets/map3d/lib/tooltips.ts | 51 ++ apps/web/src/widgets/map3d/types.ts | 6 + .../subcableInfo/SubcableInfoPanel.tsx | 156 +++- package-lock.json | 854 +++++++++++++++++- 19 files changed, 1976 insertions(+), 83 deletions(-) create mode 100644 apps/web/src/entities/vesselTrack/api/fetchTrack.ts create mode 100644 apps/web/src/entities/vesselTrack/lib/buildTrackGeoJson.ts create mode 100644 apps/web/src/entities/vesselTrack/model/types.ts create mode 100644 apps/web/src/widgets/map3d/components/VesselContextMenu.tsx create mode 100644 apps/web/src/widgets/map3d/hooks/useVesselTrackLayer.ts create mode 100644 apps/web/src/widgets/map3d/lib/shipIconCache.ts diff --git a/apps/api/src/index.ts b/apps/api/src/index.ts index bb571f7..f141ab8 100644 --- a/apps/api/src/index.ts +++ b/apps/api/src/index.ts @@ -131,6 +131,52 @@ function parseBbox(raw: string | undefined) { return { lonMin, latMin, lonMax, latMax }; } +app.get<{ + Params: { mmsi: string }; + Querystring: { minutes?: string }; +}>("/api/ais-target/:mmsi/track", async (req, reply) => { + const mmsiRaw = req.params.mmsi; + const mmsi = Number(mmsiRaw); + if (!Number.isFinite(mmsi) || mmsi <= 0 || !Number.isInteger(mmsi)) { + return reply.code(400).send({ success: false, message: "invalid mmsi", data: [], errorCode: "BAD_REQUEST" }); + } + + const minutesRaw = req.query.minutes ?? "360"; + const minutes = Number(minutesRaw); + if (!Number.isFinite(minutes) || minutes <= 0 || minutes > 7200) { + return reply.code(400).send({ success: false, message: "invalid minutes (1-7200)", data: [], errorCode: "BAD_REQUEST" }); + } + + const u = new URL(`/snp-api/api/ais-target/${mmsi}/track`, AIS_UPSTREAM_BASE); + u.searchParams.set("minutes", String(minutes)); + + const controller = new AbortController(); + const timeoutMs = 20_000; + const timeout = setTimeout(() => controller.abort(), timeoutMs); + try { + const res = await fetch(u, { signal: controller.signal, headers: { accept: "application/json" } }); + const txt = await res.text(); + if (!res.ok) { + req.log.warn({ status: res.status, body: txt.slice(0, 2000) }, "Track upstream error"); + return reply.code(502).send({ success: false, message: "upstream error", data: [], errorCode: "UPSTREAM" }); + } + + reply.type("application/json").send(txt); + } catch (e) { + const name = e instanceof Error ? e.name : ""; + const isTimeout = name === "AbortError"; + req.log.warn({ err: e, url: u.toString() }, "Track proxy request failed"); + return reply.code(isTimeout ? 504 : 502).send({ + success: false, + message: isTimeout ? `upstream timeout (${timeoutMs}ms)` : "upstream fetch failed", + data: [], + errorCode: isTimeout ? "UPSTREAM_TIMEOUT" : "UPSTREAM_FETCH_FAILED", + }); + } finally { + clearTimeout(timeout); + } +}); + app.get("/zones", async (_req, reply) => { const zonesPath = path.resolve( process.cwd(), diff --git a/apps/web/package.json b/apps/web/package.json index 39bbdea..a28712e 100644 --- a/apps/web/package.json +++ b/apps/web/package.json @@ -12,6 +12,7 @@ "dependencies": { "@deck.gl/aggregation-layers": "^9.2.7", "@deck.gl/core": "^9.2.7", + "@deck.gl/geo-layers": "^9.2.7", "@deck.gl/layers": "^9.2.7", "@deck.gl/mapbox": "^9.2.7", "@react-oauth/google": "^0.13.4", diff --git a/apps/web/src/entities/vesselTrack/api/fetchTrack.ts b/apps/web/src/entities/vesselTrack/api/fetchTrack.ts new file mode 100644 index 0000000..910b203 --- /dev/null +++ b/apps/web/src/entities/vesselTrack/api/fetchTrack.ts @@ -0,0 +1,32 @@ +import type { TrackResponse } from '../model/types'; + +const API_BASE = (import.meta.env.VITE_API_URL || '/snp-api').replace(/\/$/, ''); + +export async function fetchVesselTrack( + mmsi: number, + minutes: number, + signal?: AbortSignal, +): Promise { + const controller = new AbortController(); + const timeout = setTimeout(() => controller.abort(), 15_000); + + const combinedSignal = signal ?? controller.signal; + + try { + const url = `${API_BASE}/api/ais-target/${mmsi}/track?minutes=${minutes}`; + const res = await fetch(url, { + signal: combinedSignal, + headers: { accept: 'application/json' }, + }); + + if (!res.ok) { + const text = await res.text().catch(() => ''); + throw new Error(`Track API error ${res.status}: ${text.slice(0, 200)}`); + } + + const json = (await res.json()) as TrackResponse; + return json; + } finally { + clearTimeout(timeout); + } +} diff --git a/apps/web/src/entities/vesselTrack/lib/buildTrackGeoJson.ts b/apps/web/src/entities/vesselTrack/lib/buildTrackGeoJson.ts new file mode 100644 index 0000000..34214c7 --- /dev/null +++ b/apps/web/src/entities/vesselTrack/lib/buildTrackGeoJson.ts @@ -0,0 +1,115 @@ +import { haversineNm } from '../../../shared/lib/geo/haversineNm'; +import type { ActiveTrack, NormalizedTrip } from '../model/types'; + +/** 시간순 정렬 후 TripsLayer용 정규화 데이터 생성 */ +export function normalizeTrip( + track: ActiveTrack, + color: [number, number, number], +): NormalizedTrip { + const sorted = [...track.points].sort( + (a, b) => new Date(a.messageTimestamp).getTime() - new Date(b.messageTimestamp).getTime(), + ); + + if (sorted.length === 0) { + return { path: [], timestamps: [], mmsi: track.mmsi, name: '', color }; + } + + const baseEpoch = new Date(sorted[0].messageTimestamp).getTime(); + const path: [number, number][] = []; + const timestamps: number[] = []; + + for (const pt of sorted) { + path.push([pt.lon, pt.lat]); + // 32-bit float 정밀도를 보장하기 위해 첫 포인트 기준 초 단위 오프셋 + timestamps.push((new Date(pt.messageTimestamp).getTime() - baseEpoch) / 1000); + } + + return { + path, + timestamps, + mmsi: track.mmsi, + name: sorted[0].name || `MMSI ${track.mmsi}`, + color, + }; +} + +/** Globe 전용 — LineString GeoJSON */ +export function buildTrackLineGeoJson( + track: ActiveTrack, +): GeoJSON.FeatureCollection { + const sorted = [...track.points].sort( + (a, b) => new Date(a.messageTimestamp).getTime() - new Date(b.messageTimestamp).getTime(), + ); + + if (sorted.length < 2) { + return { type: 'FeatureCollection', features: [] }; + } + + let totalDistanceNm = 0; + const coordinates: [number, number][] = []; + for (let i = 0; i < sorted.length; i++) { + const pt = sorted[i]; + coordinates.push([pt.lon, pt.lat]); + if (i > 0) { + const prev = sorted[i - 1]; + totalDistanceNm += haversineNm(prev.lat, prev.lon, pt.lat, pt.lon); + } + } + + return { + type: 'FeatureCollection', + features: [ + { + type: 'Feature', + properties: { + mmsi: track.mmsi, + name: sorted[0].name || `MMSI ${track.mmsi}`, + pointCount: sorted.length, + minutes: track.minutes, + totalDistanceNm: Math.round(totalDistanceNm * 100) / 100, + }, + geometry: { type: 'LineString', coordinates }, + }, + ], + }; +} + +/** Globe+Mercator 공용 — Point GeoJSON */ +export function buildTrackPointsGeoJson( + track: ActiveTrack, +): GeoJSON.FeatureCollection { + const sorted = [...track.points].sort( + (a, b) => new Date(a.messageTimestamp).getTime() - new Date(b.messageTimestamp).getTime(), + ); + + return { + type: 'FeatureCollection', + features: sorted.map((pt, index) => ({ + type: 'Feature' as const, + properties: { + mmsi: pt.mmsi, + name: pt.name, + sog: pt.sog, + cog: pt.cog, + heading: pt.heading, + status: pt.status, + messageTimestamp: pt.messageTimestamp, + index, + }, + geometry: { type: 'Point' as const, coordinates: [pt.lon, pt.lat] }, + })), + }; +} + +export function getTrackTimeRange(trip: NormalizedTrip): { + minTime: number; + maxTime: number; + durationSec: number; +} { + if (trip.timestamps.length === 0) { + return { minTime: 0, maxTime: 0, durationSec: 0 }; + } + const minTime = trip.timestamps[0]; + const maxTime = trip.timestamps[trip.timestamps.length - 1]; + return { minTime, maxTime, durationSec: maxTime - minTime }; +} diff --git a/apps/web/src/entities/vesselTrack/model/types.ts b/apps/web/src/entities/vesselTrack/model/types.ts new file mode 100644 index 0000000..96df8d7 --- /dev/null +++ b/apps/web/src/entities/vesselTrack/model/types.ts @@ -0,0 +1,39 @@ +export interface TrackPoint { + mmsi: number; + name: string; + lat: number; + lon: number; + heading: number; + sog: number; + cog: number; + rot: number; + length: number; + width: number; + draught: number; + status: string; + messageTimestamp: string; + receivedDate: string; + source: string; +} + +export interface TrackResponse { + success: boolean; + message: string; + data: TrackPoint[]; +} + +export interface ActiveTrack { + mmsi: number; + minutes: number; + points: TrackPoint[]; + fetchedAt: number; +} + +/** TripsLayer용 정규화 데이터 */ +export interface NormalizedTrip { + path: [number, number][]; + timestamps: number[]; + mmsi: number; + name: string; + color: [number, number, number]; +} diff --git a/apps/web/src/pages/dashboard/DashboardPage.tsx b/apps/web/src/pages/dashboard/DashboardPage.tsx index a66abc6..20946eb 100644 --- a/apps/web/src/pages/dashboard/DashboardPage.tsx +++ b/apps/web/src/pages/dashboard/DashboardPage.tsx @@ -27,6 +27,8 @@ import { Topbar } from "../../widgets/topbar/Topbar"; import { VesselInfoPanel } from "../../widgets/info/VesselInfoPanel"; import { SubcableInfoPanel } from "../../widgets/subcableInfo/SubcableInfoPanel"; import { VesselList } from "../../widgets/vesselList/VesselList"; +import type { ActiveTrack } from "../../entities/vesselTrack/model/types"; +import { fetchVesselTrack } from "../../entities/vesselTrack/api/fetchTrack"; import { MapSettingsPanel } from "../../features/mapSettings/MapSettingsPanel"; import { DepthLegend } from "../../widgets/legend/DepthLegend"; import { DEFAULT_MAP_STYLE_SETTINGS } from "../../features/mapSettings/types"; @@ -99,7 +101,7 @@ export function DashboardPage() { const [hoveredFleetMmsiSet, setHoveredFleetMmsiSet] = useState([]); const [hoveredPairMmsiSet, setHoveredPairMmsiSet] = useState([]); const [hoveredFleetOwnerKey, setHoveredFleetOwnerKey] = useState(null); - const uid = null; + const uid = user?.id ?? null; const [typeEnabled, setTypeEnabled] = usePersistedState>( uid, 'typeEnabled', { PT: true, "PT-S": true, GN: true, OT: true, PS: true, FC: true }, ); @@ -129,6 +131,29 @@ export function DashboardPage() { const [hoveredCableId, setHoveredCableId] = useState(null); const [selectedCableId, setSelectedCableId] = useState(null); + // 항적 (vessel track) + const [activeTrack, setActiveTrack] = useState(null); + const [trackContextMenu, setTrackContextMenu] = useState<{ x: number; y: number; mmsi: number; vesselName: string } | null>(null); + const handleOpenTrackMenu = useCallback((info: { x: number; y: number; mmsi: number; vesselName: string }) => { + setTrackContextMenu(info); + }, []); + const handleCloseTrackMenu = useCallback(() => setTrackContextMenu(null), []); + const handleRequestTrack = useCallback(async (mmsi: number, minutes: number) => { + try { + const res = await fetchVesselTrack(mmsi, minutes); + if (res.success && res.data.length > 0) { + const sorted = [...res.data].sort( + (a, b) => new Date(a.messageTimestamp).getTime() - new Date(b.messageTimestamp).getTime(), + ); + setActiveTrack({ mmsi, minutes, points: sorted, fetchedAt: Date.now() }); + } else { + console.warn('Track: no data', res.message); + } + } catch (e) { + console.warn('Track fetch failed:', e); + } + }, []); + const [settings, setSettings] = usePersistedState(uid, 'map3dSettings', { showShips: true, showDensity: false, showSeamark: false, }); @@ -729,6 +754,11 @@ export function DashboardPage() { mapStyleSettings={mapStyleSettings} initialView={mapView} onViewStateChange={setMapView} + activeTrack={activeTrack} + trackContextMenu={trackContextMenu} + onRequestTrack={handleRequestTrack} + onCloseTrackMenu={handleCloseTrackMenu} + onOpenTrackMenu={handleOpenTrackMenu} /> diff --git a/apps/web/src/widgets/map3d/Map3D.tsx b/apps/web/src/widgets/map3d/Map3D.tsx index ae66351..2394a52 100644 --- a/apps/web/src/widgets/map3d/Map3D.tsx +++ b/apps/web/src/widgets/map3d/Map3D.tsx @@ -26,7 +26,9 @@ import { useGlobeOverlays } from './hooks/useGlobeOverlays'; import { useGlobeInteraction } from './hooks/useGlobeInteraction'; import { useDeckLayers } from './hooks/useDeckLayers'; import { useSubcablesLayer } from './hooks/useSubcablesLayer'; +import { useVesselTrackLayer } from './hooks/useVesselTrackLayer'; import { useMapStyleSettings } from './hooks/useMapStyleSettings'; +import { VesselContextMenu } from './components/VesselContextMenu'; export type { Map3DSettings, BaseMapId, MapProjectionId } from './types'; @@ -69,6 +71,11 @@ export function Map3D({ initialView, onViewStateChange, onGlobeShipsReady, + activeTrack = null, + trackContextMenu = null, + onRequestTrack, + onCloseTrackMenu, + onOpenTrackMenu, }: Props) { void onHoverFleet; void onClearFleetHover; @@ -94,6 +101,7 @@ export function Map3D({ // ── Hover state ────────────────────────────────────────────────────── const { + hoveredDeckMmsiSet: hoveredDeckMmsiArr, setHoveredDeckMmsiSet, setHoveredDeckPairMmsiSet, setHoveredDeckFleetOwnerKey, @@ -527,10 +535,80 @@ export function Map3D({ }, ); + useVesselTrackLayer( + mapRef, overlayRef, projectionBusyRef, reorderGlobeFeatureLayers, + { activeTrack, projection, mapSyncEpoch }, + ); + + // 우클릭 컨텍스트 메뉴 — 대상선박(legacyHits)만 허용 + // Mercator: Deck.gl hover 상태에서 MMSI 참조, Globe: queryRenderedFeatures + const hoveredDeckMmsiRef = useRef(hoveredDeckMmsiArr); + useEffect(() => { hoveredDeckMmsiRef.current = hoveredDeckMmsiArr; }, [hoveredDeckMmsiArr]); + + useEffect(() => { + const container = containerRef.current; + if (!container) return; + const onContextMenu = (e: MouseEvent) => { + e.preventDefault(); + if (!onOpenTrackMenu) return; + const map = mapRef.current; + if (!map || !map.isStyleLoaded() || projectionBusyRef.current) return; + + let mmsi: number | null = null; + + if (projectionRef.current === 'globe') { + // Globe: MapLibre 네이티브 레이어에서 쿼리 + const point: [number, number] = [e.offsetX, e.offsetY]; + const shipLayerIds = [ + 'ships-globe', 'ships-globe-halo', 'ships-globe-outline', + ].filter((id) => map.getLayer(id)); + + let features: maplibregl.MapGeoJSONFeature[] = []; + try { + if (shipLayerIds.length > 0) { + features = map.queryRenderedFeatures(point, { layers: shipLayerIds }); + } + } catch { /* ignore */ } + + if (features.length > 0) { + const props = features[0].properties || {}; + const raw = typeof props.mmsi === 'number' ? props.mmsi : Number(props.mmsi); + if (Number.isFinite(raw) && raw > 0) mmsi = raw; + } + } else { + // Mercator: Deck.gl hover 상태에서 현재 호버된 MMSI 사용 + const hovered = hoveredDeckMmsiRef.current; + if (hovered.length > 0) mmsi = hovered[0]; + } + + if (mmsi == null || !legacyHits?.has(mmsi)) return; + + const target = shipByMmsi.get(mmsi); + const vesselName = (target?.name || '').trim() || `MMSI ${mmsi}`; + onOpenTrackMenu({ x: e.clientX, y: e.clientY, mmsi, vesselName }); + }; + container.addEventListener('contextmenu', onContextMenu); + return () => container.removeEventListener('contextmenu', onContextMenu); + }, [onOpenTrackMenu, legacyHits, shipByMmsi]); + useFlyTo( mapRef, projectionRef, { selectedMmsi, shipData, fleetFocusId, fleetFocusLon, fleetFocusLat, fleetFocusZoom }, ); - return
; + return ( + <> +
+ {trackContextMenu && onRequestTrack && onCloseTrackMenu && ( + + )} + + ); } diff --git a/apps/web/src/widgets/map3d/components/VesselContextMenu.tsx b/apps/web/src/widgets/map3d/components/VesselContextMenu.tsx new file mode 100644 index 0000000..5cc0591 --- /dev/null +++ b/apps/web/src/widgets/map3d/components/VesselContextMenu.tsx @@ -0,0 +1,135 @@ +import { useEffect, useRef } from 'react'; + +interface Props { + x: number; + y: number; + mmsi: number; + vesselName: string; + onRequestTrack: (mmsi: number, minutes: number) => void; + onClose: () => void; +} + +const TRACK_OPTIONS = [ + { label: '6시간', minutes: 360 }, + { label: '12시간', minutes: 720 }, + { label: '1일', minutes: 1440 }, + { label: '3일', minutes: 4320 }, + { label: '5일', minutes: 7200 }, +] as const; + +const MENU_WIDTH = 180; +const MENU_PAD = 8; + +export function VesselContextMenu({ x, y, mmsi, vesselName, onRequestTrack, onClose }: Props) { + const ref = useRef(null); + + // 화면 밖 보정 + const left = Math.min(x, window.innerWidth - MENU_WIDTH - MENU_PAD); + const maxTop = window.innerHeight - (TRACK_OPTIONS.length * 30 + 56) - MENU_PAD; + const top = Math.min(y, maxTop); + + useEffect(() => { + const onKey = (e: KeyboardEvent) => { + if (e.key === 'Escape') onClose(); + }; + const onClick = (e: MouseEvent) => { + if (ref.current && !ref.current.contains(e.target as Node)) onClose(); + }; + const onScroll = () => onClose(); + + window.addEventListener('keydown', onKey); + window.addEventListener('mousedown', onClick, true); + window.addEventListener('scroll', onScroll, true); + return () => { + window.removeEventListener('keydown', onKey); + window.removeEventListener('mousedown', onClick, true); + window.removeEventListener('scroll', onScroll, true); + }; + }, [onClose]); + + const handleSelect = (minutes: number) => { + onRequestTrack(mmsi, minutes); + onClose(); + }; + + return ( +
+ {/* Header */} +
+ {vesselName} +
+ + {/* 항적조회 항목 */} +
+ 항적조회 +
+ + {TRACK_OPTIONS.map((opt) => ( + + ))} +
+ ); +} diff --git a/apps/web/src/widgets/map3d/hooks/useDeckLayers.ts b/apps/web/src/widgets/map3d/hooks/useDeckLayers.ts index af52639..fcabe3b 100644 --- a/apps/web/src/widgets/map3d/hooks/useDeckLayers.ts +++ b/apps/web/src/widgets/map3d/hooks/useDeckLayers.ts @@ -50,6 +50,7 @@ import { getFleetCircleTooltipHtml, } from '../lib/tooltips'; import { sanitizeDeckLayerList } from '../lib/mapCore'; +import { getCachedShipIcon } from '../lib/shipIconCache'; // NOTE: // Globe mode now relies on MapLibre native overlays (useGlobeOverlays/useGlobeShips). @@ -380,7 +381,7 @@ export function useDeckLayers( pickable: true, billboard: false, parameters: overlayParams, - iconAtlas: '/assets/ship.svg', + iconAtlas: getCachedShipIcon(), iconMapping: SHIP_ICON_MAPPING, getIcon: () => 'ship', getPosition: (d) => [d.lon, d.lat] as [number, number], @@ -403,7 +404,7 @@ export function useDeckLayers( pickable: false, billboard: false, parameters: overlayParams, - iconAtlas: '/assets/ship.svg', + iconAtlas: getCachedShipIcon(), iconMapping: SHIP_ICON_MAPPING, getIcon: () => 'ship', getPosition: (d) => [d.lon, d.lat] as [number, number], @@ -448,7 +449,7 @@ export function useDeckLayers( pickable: true, billboard: false, parameters: overlayParams, - iconAtlas: '/assets/ship.svg', + iconAtlas: getCachedShipIcon(), iconMapping: SHIP_ICON_MAPPING, getIcon: () => 'ship', getPosition: (d) => [d.lon, d.lat] as [number, number], @@ -484,7 +485,7 @@ export function useDeckLayers( if (settings.showShips && shipOverlayLayerData.filter((t) => legacyHits?.has(t.mmsi)).length > 0) { const shipOverlayTargetData2 = shipOverlayLayerData.filter((t) => legacyHits?.has(t.mmsi)); - layers.push(new IconLayer({ id: 'ships-overlay-target', data: shipOverlayTargetData2, pickable: false, billboard: false, parameters: overlayParams, iconAtlas: '/assets/ship.svg', iconMapping: SHIP_ICON_MAPPING, getIcon: () => 'ship', getPosition: (d) => [d.lon, d.lat] as [number, number], getAngle: (d) => -getDisplayHeading({ cog: d.cog, heading: d.heading }), sizeUnits: 'pixels', getSize: (d) => { if (selectedMmsi != null && d.mmsi === selectedMmsi) return FLAT_SHIP_ICON_SIZE_SELECTED; if (shipHighlightSet.has(d.mmsi)) return FLAT_SHIP_ICON_SIZE_HIGHLIGHTED; return 0; }, getColor: (d) => { if (!shipHighlightSet.has(d.mmsi) && !(selectedMmsi != null && d.mmsi === selectedMmsi)) return [0, 0, 0, 0]; return getShipColor(d, selectedMmsi, legacyHits?.get(d.mmsi)?.shipCode ?? null, shipHighlightSet); } })); + layers.push(new IconLayer({ id: 'ships-overlay-target', data: shipOverlayTargetData2, pickable: false, billboard: false, parameters: overlayParams, iconAtlas: getCachedShipIcon(), iconMapping: SHIP_ICON_MAPPING, getIcon: () => 'ship', getPosition: (d) => [d.lon, d.lat] as [number, number], getAngle: (d) => -getDisplayHeading({ cog: d.cog, heading: d.heading }), sizeUnits: 'pixels', getSize: (d) => { if (selectedMmsi != null && d.mmsi === selectedMmsi) return FLAT_SHIP_ICON_SIZE_SELECTED; if (shipHighlightSet.has(d.mmsi)) return FLAT_SHIP_ICON_SIZE_HIGHLIGHTED; return 0; }, getColor: (d) => { if (!shipHighlightSet.has(d.mmsi) && !(selectedMmsi != null && d.mmsi === selectedMmsi)) return [0, 0, 0, 0]; return getShipColor(d, selectedMmsi, legacyHits?.get(d.mmsi)?.shipCode ?? null, shipHighlightSet); } })); } const normalizedLayers = sanitizeDeckLayerList(layers); diff --git a/apps/web/src/widgets/map3d/hooks/useMapStyleSettings.ts b/apps/web/src/widgets/map3d/hooks/useMapStyleSettings.ts index 9edefc9..1165ab9 100644 --- a/apps/web/src/widgets/map3d/hooks/useMapStyleSettings.ts +++ b/apps/web/src/widgets/map3d/hooks/useMapStyleSettings.ts @@ -63,6 +63,7 @@ function applyLandColor(map: maplibregl.Map, color: string) { if (id.startsWith('fc-')) continue; if (id.startsWith('fleet-')) continue; if (id.startsWith('predict-')) continue; + if (id.startsWith('vessel-track-')) continue; if (id === 'deck-globe') continue; const sourceLayer = String((layer as Record)['source-layer'] ?? ''); const isWater = waterRegex.test(id) || waterRegex.test(sourceLayer); diff --git a/apps/web/src/widgets/map3d/hooks/useProjectionToggle.ts b/apps/web/src/widgets/map3d/hooks/useProjectionToggle.ts index 9bc33de..4b3e3cc 100644 --- a/apps/web/src/widgets/map3d/hooks/useProjectionToggle.ts +++ b/apps/web/src/widgets/map3d/hooks/useProjectionToggle.ts @@ -94,6 +94,11 @@ export function useProjectionToggle( 'subcables-glow', 'subcables-points', 'subcables-label', + 'vessel-track-line', + 'vessel-track-line-hitarea', + 'vessel-track-arrow', + 'vessel-track-pts', + 'vessel-track-pts-highlight', 'zones-fill', 'zones-line', 'zones-label', diff --git a/apps/web/src/widgets/map3d/hooks/useSubcablesLayer.ts b/apps/web/src/widgets/map3d/hooks/useSubcablesLayer.ts index 7fcc138..23c290c 100644 --- a/apps/web/src/widgets/map3d/hooks/useSubcablesLayer.ts +++ b/apps/web/src/widgets/map3d/hooks/useSubcablesLayer.ts @@ -16,6 +16,7 @@ const LINE_ID = 'subcables-line'; const GLOW_ID = 'subcables-glow'; const POINTS_ID = 'subcables-points'; const LABEL_ID = 'subcables-label'; +const HOVER_LABEL_ID = 'subcables-hover-label'; /* ── Paint defaults (used for layer creation + hover reset) ──────── */ const LINE_OPACITY_DEFAULT = ['interpolate', ['linear'], ['zoom'], 2, 0.7, 6, 0.82, 10, 0.92]; @@ -63,10 +64,10 @@ const LAYER_SPECS: NativeLayerSpec[] = [ type: 'line', sourceId: SRC_ID, paint: { - 'line-color': ['get', 'color'], + 'line-color': '#ffffff', 'line-opacity': 0, - 'line-width': ['interpolate', ['linear'], ['zoom'], 2, 5, 6, 8, 10, 12], - 'line-blur': ['interpolate', ['linear'], ['zoom'], 2, 3, 6, 5, 10, 7], + 'line-width': ['interpolate', ['linear'], ['zoom'], 2, 10, 6, 16, 10, 24], + 'line-blur': ['interpolate', ['linear'], ['zoom'], 2, 4, 6, 6, 10, 8], }, filter: ['==', ['get', 'id'], ''], layout: { 'line-cap': 'round', 'line-join': 'round' }, @@ -107,6 +108,29 @@ const LAYER_SPECS: NativeLayerSpec[] = [ }, minzoom: 4, }, + { + id: HOVER_LABEL_ID, + type: 'symbol', + sourceId: SRC_ID, + paint: { + 'text-color': '#ffffff', + 'text-halo-color': 'rgba(0,0,0,0.85)', + 'text-halo-width': 2, + 'text-halo-blur': 0.5, + 'text-opacity': 0, + }, + layout: { + 'symbol-placement': 'line', + 'text-field': ['get', 'name'], + 'text-size': ['interpolate', ['linear'], ['zoom'], 2, 14, 6, 17, 10, 20], + 'text-font': ['Noto Sans Bold', 'Open Sans Bold'], + 'text-allow-overlap': true, + 'text-padding': 2, + 'text-rotation-alignment': 'map', + }, + filter: ['==', ['get', 'id'], ''], + minzoom: 2, + }, ]; export function useSubcablesLayer( @@ -250,42 +274,27 @@ export function useSubcablesLayer( } /* ── Hover highlight helper (paint-only mutations) ────────────────── */ +// 기본 레이어는 항상 기본값 유지, glow 레이어(filter 기반)로만 호버 강조 function applyHoverHighlight(map: maplibregl.Map, hoveredId: string | null) { + const noMatch = ['==', ['get', 'id'], ''] as never; if (hoveredId) { const matchExpr = ['==', ['get', 'id'], hoveredId]; - - if (map.getLayer(LINE_ID)) { - map.setPaintProperty(LINE_ID, 'line-opacity', ['case', matchExpr, 1.0, 0.2] as never); - map.setPaintProperty(LINE_ID, 'line-width', ['case', matchExpr, 4.5, 1.2] as never); - } - if (map.getLayer(CASING_ID)) { - map.setPaintProperty(CASING_ID, 'line-opacity', ['case', matchExpr, 0.7, 0.12] as never); - map.setPaintProperty(CASING_ID, 'line-width', ['case', matchExpr, 6.5, 2.0] as never); - } if (map.getLayer(GLOW_ID)) { map.setFilter(GLOW_ID, matchExpr as never); - map.setPaintProperty(GLOW_ID, 'line-opacity', 0.35); + map.setPaintProperty(GLOW_ID, 'line-opacity', 0.55); } - if (map.getLayer(POINTS_ID)) { - map.setPaintProperty(POINTS_ID, 'circle-opacity', ['case', matchExpr, 1.0, 0.15] as never); - map.setPaintProperty(POINTS_ID, 'circle-radius', ['case', matchExpr, 4, 1.5] as never); + if (map.getLayer(HOVER_LABEL_ID)) { + map.setFilter(HOVER_LABEL_ID, matchExpr as never); + map.setPaintProperty(HOVER_LABEL_ID, 'text-opacity', 1.0); } } else { - if (map.getLayer(LINE_ID)) { - map.setPaintProperty(LINE_ID, 'line-opacity', LINE_OPACITY_DEFAULT as never); - map.setPaintProperty(LINE_ID, 'line-width', LINE_WIDTH_DEFAULT as never); - } - if (map.getLayer(CASING_ID)) { - map.setPaintProperty(CASING_ID, 'line-opacity', CASING_OPACITY_DEFAULT as never); - map.setPaintProperty(CASING_ID, 'line-width', CASING_WIDTH_DEFAULT as never); - } if (map.getLayer(GLOW_ID)) { - map.setFilter(GLOW_ID, ['==', ['get', 'id'], ''] as never); + map.setFilter(GLOW_ID, noMatch); map.setPaintProperty(GLOW_ID, 'line-opacity', 0); } - if (map.getLayer(POINTS_ID)) { - map.setPaintProperty(POINTS_ID, 'circle-opacity', POINTS_OPACITY_DEFAULT as never); - map.setPaintProperty(POINTS_ID, 'circle-radius', POINTS_RADIUS_DEFAULT as never); + if (map.getLayer(HOVER_LABEL_ID)) { + map.setFilter(HOVER_LABEL_ID, noMatch); + map.setPaintProperty(HOVER_LABEL_ID, 'text-opacity', 0); } } } diff --git a/apps/web/src/widgets/map3d/hooks/useVesselTrackLayer.ts b/apps/web/src/widgets/map3d/hooks/useVesselTrackLayer.ts new file mode 100644 index 0000000..e46bc6f --- /dev/null +++ b/apps/web/src/widgets/map3d/hooks/useVesselTrackLayer.ts @@ -0,0 +1,395 @@ +/** + * useVesselTrackLayer — 항적(Track) 렌더링 hook + * + * Mercator: TripsLayer 애니메이션 + ScatterplotLayer 포인트 + * Globe: MapLibre 네이티브 line + circle + symbol(arrow) + */ +import { useCallback, useEffect, useMemo, useRef, type MutableRefObject } from 'react'; +import maplibregl from 'maplibre-gl'; +import { TripsLayer } from '@deck.gl/geo-layers'; +import { PathLayer, ScatterplotLayer } from '@deck.gl/layers'; +import { MapboxOverlay } from '@deck.gl/mapbox'; +import type { ActiveTrack, NormalizedTrip, TrackPoint } from '../../../entities/vesselTrack/model/types'; +import { + normalizeTrip, + buildTrackLineGeoJson, + buildTrackPointsGeoJson, + getTrackTimeRange, +} from '../../../entities/vesselTrack/lib/buildTrackGeoJson'; +import { getTrackLineTooltipHtml, getTrackPointTooltipHtml } from '../lib/tooltips'; +import { useNativeMapLayers, type NativeLayerSpec, type NativeSourceConfig } from './useNativeMapLayers'; +import type { MapProjectionId } from '../types'; + +/* ── Constants ──────────────────────────────────────────────────────── */ +const TRACK_COLOR: [number, number, number] = [0, 224, 255]; // cyan +const TRACK_COLOR_CSS = `rgb(${TRACK_COLOR.join(',')})`; + +// Globe 네이티브 레이어/소스 ID +const LINE_SRC = 'vessel-track-line-src'; +const PTS_SRC = 'vessel-track-pts-src'; +const LINE_ID = 'vessel-track-line'; +const ARROW_ID = 'vessel-track-arrow'; +const HITAREA_ID = 'vessel-track-line-hitarea'; +const PTS_ID = 'vessel-track-pts'; +const PTS_HL_ID = 'vessel-track-pts-highlight'; + +// Mercator Deck.gl 레이어 ID +const DECK_PATH_ID = 'vessel-track-path'; +const DECK_TRIPS_ID = 'vessel-track-trips'; +const DECK_POINTS_ID = 'vessel-track-deck-pts'; + +/* ── Globe 네이티브 레이어 스펙 ────────────────────────────────────── */ +const GLOBE_LAYERS: NativeLayerSpec[] = [ + { + id: LINE_ID, + type: 'line', + sourceId: LINE_SRC, + paint: { + 'line-color': TRACK_COLOR_CSS, + 'line-width': ['interpolate', ['linear'], ['zoom'], 2, 2, 6, 3, 10, 4], + 'line-opacity': 0.8, + }, + layout: { 'line-cap': 'round', 'line-join': 'round' }, + }, + { + id: HITAREA_ID, + type: 'line', + sourceId: LINE_SRC, + paint: { 'line-color': 'rgba(0,0,0,0)', 'line-width': 14, 'line-opacity': 0 }, + layout: { 'line-cap': 'round', 'line-join': 'round' }, + }, + { + id: ARROW_ID, + type: 'symbol', + sourceId: LINE_SRC, + paint: { + 'text-color': TRACK_COLOR_CSS, + 'text-opacity': 0.7, + }, + layout: { + 'symbol-placement': 'line', + 'text-field': '▶', + 'text-size': 10, + 'symbol-spacing': 80, + 'text-rotation-alignment': 'map', + 'text-allow-overlap': true, + 'text-ignore-placement': true, + }, + }, + { + id: PTS_ID, + type: 'circle', + sourceId: PTS_SRC, + paint: { + 'circle-radius': ['interpolate', ['linear'], ['zoom'], 2, 2, 6, 3, 10, 5], + 'circle-color': TRACK_COLOR_CSS, + 'circle-stroke-width': 1, + 'circle-stroke-color': 'rgba(0,0,0,0.5)', + 'circle-opacity': 0.85, + }, + }, + { + id: PTS_HL_ID, + type: 'circle', + sourceId: PTS_SRC, + paint: { + 'circle-radius': ['interpolate', ['linear'], ['zoom'], 2, 6, 6, 8, 10, 12], + 'circle-color': '#ffffff', + 'circle-stroke-width': 2, + 'circle-stroke-color': TRACK_COLOR_CSS, + 'circle-opacity': 0, + }, + filter: ['==', ['get', 'index'], -1], + }, +]; + +/* ── Animation speed: 전체 궤적을 ~20초에 재생 ────────────────────── */ +const ANIM_CYCLE_SEC = 20; + +/* ── Hook ──────────────────────────────────────────────────────────── */ +export function useVesselTrackLayer( + mapRef: MutableRefObject, + overlayRef: MutableRefObject, + projectionBusyRef: MutableRefObject, + reorderGlobeFeatureLayers: () => void, + opts: { + activeTrack: ActiveTrack | null; + projection: MapProjectionId; + mapSyncEpoch: number; + }, +) { + const { activeTrack, projection, mapSyncEpoch } = opts; + + /* ── 정규화 데이터 ── */ + const normalizedTrip = useMemo(() => { + if (!activeTrack || activeTrack.points.length < 2) return null; + return normalizeTrip(activeTrack, TRACK_COLOR); + }, [activeTrack]); + + const timeRange = useMemo(() => { + if (!normalizedTrip) return null; + return getTrackTimeRange(normalizedTrip); + }, [normalizedTrip]); + + /* ── Globe 네이티브 GeoJSON ── */ + const lineGeoJson = useMemo(() => { + if (!activeTrack || activeTrack.points.length < 2) return null; + return buildTrackLineGeoJson(activeTrack); + }, [activeTrack]); + + const pointsGeoJson = useMemo(() => { + if (!activeTrack || activeTrack.points.length === 0) return null; + return buildTrackPointsGeoJson(activeTrack); + }, [activeTrack]); + + /* ── Globe 네이티브 레이어 (useNativeMapLayers) ── */ + const globeSources = useMemo(() => [ + { id: LINE_SRC, data: lineGeoJson, options: { lineMetrics: true } }, + { id: PTS_SRC, data: pointsGeoJson }, + ], [lineGeoJson, pointsGeoJson]); + + const isGlobeVisible = projection === 'globe' && activeTrack != null && activeTrack.points.length >= 2; + + useNativeMapLayers( + mapRef, + projectionBusyRef, + reorderGlobeFeatureLayers, + { + sources: globeSources, + layers: GLOBE_LAYERS, + visible: isGlobeVisible, + beforeLayer: ['zones-fill', 'zones-line'], + }, + [lineGeoJson, pointsGeoJson, isGlobeVisible, projection, mapSyncEpoch], + ); + + /* ── Globe 호버 툴팁 ── */ + const tooltipRef = useRef(null); + + const clearTooltip = useCallback(() => { + try { tooltipRef.current?.remove(); } catch { /* ignore */ } + tooltipRef.current = null; + }, []); + + useEffect(() => { + const map = mapRef.current; + if (!map || projection !== 'globe' || !activeTrack) { + clearTooltip(); + return; + } + + const onMove = (e: maplibregl.MapMouseEvent) => { + if (projectionBusyRef.current || !map.isStyleLoaded()) { + clearTooltip(); + return; + } + + const layers = [PTS_ID, HITAREA_ID].filter((id) => map.getLayer(id)); + if (layers.length === 0) { clearTooltip(); return; } + + let features: maplibregl.MapGeoJSONFeature[] = []; + try { + features = map.queryRenderedFeatures(e.point, { layers }); + } catch { /* ignore */ } + + if (features.length === 0) { + clearTooltip(); + // 하이라이트 리셋 + try { + if (map.getLayer(PTS_HL_ID)) { + map.setFilter(PTS_HL_ID, ['==', ['get', 'index'], -1] as never); + map.setPaintProperty(PTS_HL_ID, 'circle-opacity', 0); + } + } catch { /* ignore */ } + return; + } + + const feat = features[0]; + const props = feat.properties || {}; + const layerId = feat.layer?.id; + let tooltipHtml = ''; + + if (layerId === PTS_ID && props.index != null) { + tooltipHtml = getTrackPointTooltipHtml({ + name: String(props.name ?? ''), + sog: Number(props.sog), + cog: Number(props.cog), + heading: Number(props.heading), + status: String(props.status ?? ''), + messageTimestamp: String(props.messageTimestamp ?? ''), + }).html; + // 하이라이트 + try { + if (map.getLayer(PTS_HL_ID)) { + map.setFilter(PTS_HL_ID, ['==', ['get', 'index'], Number(props.index)] as never); + map.setPaintProperty(PTS_HL_ID, 'circle-opacity', 0.8); + } + } catch { /* ignore */ } + } else if (layerId === HITAREA_ID) { + tooltipHtml = getTrackLineTooltipHtml({ + name: String(props.name ?? ''), + pointCount: Number(props.pointCount ?? 0), + minutes: Number(props.minutes ?? 0), + totalDistanceNm: Number(props.totalDistanceNm ?? 0), + }).html; + } + + if (!tooltipHtml) { clearTooltip(); return; } + + if (!tooltipRef.current) { + tooltipRef.current = new maplibregl.Popup({ + closeButton: false, closeOnClick: false, + maxWidth: '360px', className: 'maplibre-tooltip-popup', + }); + } + const container = document.createElement('div'); + container.className = 'maplibre-tooltip-popup__content'; + container.innerHTML = tooltipHtml; + tooltipRef.current.setLngLat(e.lngLat).setDOMContent(container).addTo(map); + }; + + const onOut = () => { + clearTooltip(); + try { + if (map.getLayer(PTS_HL_ID)) { + map.setFilter(PTS_HL_ID, ['==', ['get', 'index'], -1] as never); + map.setPaintProperty(PTS_HL_ID, 'circle-opacity', 0); + } + } catch { /* ignore */ } + }; + + map.on('mousemove', onMove); + map.on('mouseout', onOut); + return () => { + map.off('mousemove', onMove); + map.off('mouseout', onOut); + clearTooltip(); + }; + }, [projection, activeTrack, clearTooltip]); + + /* ── Mercator: 정적 레이어 1회 생성 + rAF 애니메이션 (React state 미사용) ── */ + const animRef = useRef(0); + + useEffect(() => { + const overlay = overlayRef.current; + if (!overlay || projection !== 'mercator') { + cancelAnimationFrame(animRef.current); + return; + } + + const isTrackLayer = (id?: string) => + id === DECK_PATH_ID || id === DECK_TRIPS_ID || id === DECK_POINTS_ID; + + if (!normalizedTrip || !activeTrack || activeTrack.points.length < 2 || !timeRange || timeRange.durationSec === 0) { + cancelAnimationFrame(animRef.current); + try { + const existing = (overlay as unknown as { props?: { layers?: unknown[] } }).props?.layers ?? []; + const filtered = (existing as { id?: string }[]).filter((l) => !isTrackLayer(l.id)); + if (filtered.length !== (existing as unknown[]).length) { + overlay.setProps({ layers: filtered } as never); + } + } catch { /* ignore */ } + return; + } + + // 정적 레이어: activeTrack 변경 시 1회만 생성, rAF 루프에서 재사용 + const pathLayer = new PathLayer({ + id: DECK_PATH_ID, + data: [normalizedTrip], + getPath: (d) => d.path, + getColor: [...TRACK_COLOR, 90] as [number, number, number, number], + getWidth: 2, + widthMinPixels: 2, + widthUnits: 'pixels' as const, + capRounded: true, + jointRounded: true, + pickable: false, + }); + + const sorted = [...activeTrack.points].sort( + (a, b) => new Date(a.messageTimestamp).getTime() - new Date(b.messageTimestamp).getTime(), + ); + + const pointsLayer = new ScatterplotLayer({ + id: DECK_POINTS_ID, + data: sorted, + getPosition: (d) => [d.lon, d.lat], + getRadius: 4, + radiusUnits: 'pixels' as const, + getFillColor: TRACK_COLOR, + getLineColor: [0, 0, 0, 128], + lineWidthMinPixels: 1, + stroked: true, + pickable: true, + }); + + // rAF 루프: TripsLayer만 매 프레임 갱신 (React 재렌더링 없음) + const { minTime, maxTime, durationSec } = timeRange; + const speed = durationSec / ANIM_CYCLE_SEC; + let current = minTime; + + const loop = () => { + current += speed / 60; + if (current > maxTime) current = minTime; + + const tripsLayer = new TripsLayer({ + id: DECK_TRIPS_ID, + data: [normalizedTrip], + getPath: (d: NormalizedTrip) => d.path, + getTimestamps: (d: NormalizedTrip) => d.timestamps, + getColor: (d: NormalizedTrip) => d.color, + currentTime: current, + trailLength: durationSec * 0.15, + fadeTrail: true, + widthMinPixels: 4, + capRounded: true, + jointRounded: true, + pickable: false, + }); + + try { + const existing = (overlay as unknown as { props?: { layers?: unknown[] } }).props?.layers ?? []; + const filtered = (existing as { id?: string }[]).filter((l) => !isTrackLayer(l.id)); + overlay.setProps({ layers: [...filtered, pathLayer, tripsLayer, pointsLayer] } as never); + } catch { /* ignore */ } + + animRef.current = requestAnimationFrame(loop); + }; + + animRef.current = requestAnimationFrame(loop); + return () => cancelAnimationFrame(animRef.current); + }, [projection, normalizedTrip, activeTrack, timeRange]); + + /* ── 항적 조회 시 자동 fitBounds ── */ + useEffect(() => { + const map = mapRef.current; + if (!map || !activeTrack || activeTrack.points.length < 2) return; + if (projectionBusyRef.current) return; + + let minLon = Infinity; + let minLat = Infinity; + let maxLon = -Infinity; + let maxLat = -Infinity; + for (const pt of activeTrack.points) { + if (pt.lon < minLon) minLon = pt.lon; + if (pt.lat < minLat) minLat = pt.lat; + if (pt.lon > maxLon) maxLon = pt.lon; + if (pt.lat > maxLat) maxLat = pt.lat; + } + + const fitOpts = { padding: 80, duration: 1000, maxZoom: 14 }; + const apply = () => { + try { + map.fitBounds([[minLon, minLat], [maxLon, maxLat]], fitOpts); + } catch { /* ignore */ } + }; + + if (map.isStyleLoaded()) { + apply(); + } else { + const onLoad = () => { apply(); map.off('styledata', onLoad); }; + map.on('styledata', onLoad); + return () => { map.off('styledata', onLoad); }; + } + }, [activeTrack]); +} diff --git a/apps/web/src/widgets/map3d/lib/layerHelpers.ts b/apps/web/src/widgets/map3d/lib/layerHelpers.ts index f5277a2..ddf9a54 100644 --- a/apps/web/src/widgets/map3d/lib/layerHelpers.ts +++ b/apps/web/src/widgets/map3d/lib/layerHelpers.ts @@ -36,6 +36,11 @@ const GLOBE_NATIVE_LAYER_IDS = [ 'subcables-glow', 'subcables-points', 'subcables-label', + 'vessel-track-line', + 'vessel-track-line-hitarea', + 'vessel-track-arrow', + 'vessel-track-pts', + 'vessel-track-pts-highlight', 'deck-globe', ]; @@ -46,6 +51,8 @@ const GLOBE_NATIVE_SOURCE_IDS = [ 'pair-range-ml-src', 'subcables-src', 'subcables-pts-src', + 'vessel-track-line-src', + 'vessel-track-pts-src', ]; export function clearGlobeNativeLayers(map: maplibregl.Map) { diff --git a/apps/web/src/widgets/map3d/lib/shipIconCache.ts b/apps/web/src/widgets/map3d/lib/shipIconCache.ts new file mode 100644 index 0000000..b7bdd8e --- /dev/null +++ b/apps/web/src/widgets/map3d/lib/shipIconCache.ts @@ -0,0 +1,30 @@ +/** + * Ship SVG 아이콘을 미리 fetch하여 data URL로 캐시. + * Deck.gl IconLayer가 매번 iconAtlas URL을 fetch하지 않도록 + * 인라인 data URL을 전달한다. + */ +const SHIP_SVG_URL = '/assets/ship.svg'; + +let _cachedDataUrl: string | null = null; +let _promise: Promise | null = null; + +function preloadShipIcon(): Promise { + if (_cachedDataUrl) return Promise.resolve(_cachedDataUrl); + if (_promise) return _promise; + _promise = fetch(SHIP_SVG_URL) + .then((res) => res.text()) + .then((svg) => { + _cachedDataUrl = `data:image/svg+xml;base64,${btoa(svg)}`; + return _cachedDataUrl; + }) + .catch(() => SHIP_SVG_URL); + return _promise; +} + +/** 캐시된 data URL 또는 폴백 URL 반환 */ +export function getCachedShipIcon(): string { + return _cachedDataUrl ?? SHIP_SVG_URL; +} + +// 모듈 임포트 시 즉시 로드 시작 +preloadShipIcon(); diff --git a/apps/web/src/widgets/map3d/lib/tooltips.ts b/apps/web/src/widgets/map3d/lib/tooltips.ts index 5fe4996..340b2f3 100644 --- a/apps/web/src/widgets/map3d/lib/tooltips.ts +++ b/apps/web/src/widgets/map3d/lib/tooltips.ts @@ -168,3 +168,54 @@ export function getFleetCircleTooltipHtml({
`, }; } + +function fmtMinutesKr(minutes: number): string { + if (minutes < 60) return `${minutes}분`; + if (minutes < 1440) return `${Math.round(minutes / 60)}시간`; + return `${Math.round(minutes / 1440)}일`; +} + +export function getTrackLineTooltipHtml({ + name, + pointCount, + minutes, + totalDistanceNm, +}: { + name: string; + pointCount: number; + minutes: number; + totalDistanceNm: number; +}) { + return { + html: `
+
항적 · ${name}
+
기간: ${fmtMinutesKr(minutes)} · 포인트: ${pointCount}
+
총 거리: ${totalDistanceNm.toFixed(1)} NM
+
`, + }; +} + +export function getTrackPointTooltipHtml({ + name, + sog, + cog, + heading, + status, + messageTimestamp, +}: { + name: string; + sog: number; + cog: number; + heading: number; + status: string; + messageTimestamp: string; +}) { + return { + html: `
+
${name}
+
SOG: ${isFiniteNumber(sog) ? sog : '?'} kt · COG: ${isFiniteNumber(cog) ? cog : '?'}°
+
Heading: ${isFiniteNumber(heading) ? heading : '?'}° · 상태: ${status || '-'}
+
${fmtIsoFull(messageTimestamp)}
+
`, + }; +} diff --git a/apps/web/src/widgets/map3d/types.ts b/apps/web/src/widgets/map3d/types.ts index aa6394d..55bfabf 100644 --- a/apps/web/src/widgets/map3d/types.ts +++ b/apps/web/src/widgets/map3d/types.ts @@ -1,6 +1,7 @@ import type { AisTarget } from '../../entities/aisTarget/model/types'; import type { LegacyVesselInfo } from '../../entities/legacyVessel/model/types'; import type { SubcableGeoJson } from '../../entities/subcable/model/types'; +import type { ActiveTrack } from '../../entities/vesselTrack/model/types'; import type { ZonesGeoJson } from '../../entities/zone/api/useZones'; import type { MapToggleState } from '../../features/mapToggles/MapToggles'; import type { FcLink, FleetCircle, PairLink } from '../../features/legacyDashboard/model/types'; @@ -62,6 +63,11 @@ export interface Map3DProps { initialView?: MapViewState | null; onViewStateChange?: (view: MapViewState) => void; onGlobeShipsReady?: (ready: boolean) => void; + activeTrack?: ActiveTrack | null; + trackContextMenu?: { x: number; y: number; mmsi: number; vesselName: string } | null; + onRequestTrack?: (mmsi: number, minutes: number) => void; + onCloseTrackMenu?: () => void; + onOpenTrackMenu?: (info: { x: number; y: number; mmsi: number; vesselName: string }) => void; } export type DashSeg = { diff --git a/apps/web/src/widgets/subcableInfo/SubcableInfoPanel.tsx b/apps/web/src/widgets/subcableInfo/SubcableInfoPanel.tsx index 9bfec56..07d831a 100644 --- a/apps/web/src/widgets/subcableInfo/SubcableInfoPanel.tsx +++ b/apps/web/src/widgets/subcableInfo/SubcableInfoPanel.tsx @@ -11,97 +11,161 @@ export function SubcableInfoPanel({ detail, color, onClose }: Props) { const countries = [...new Set(detail.landing_points.map((lp) => lp.country).filter(Boolean))]; return ( -
+
-
+ {/* ── Header ── */} +
{color && (
)} -
{detail.name}
+
+ {detail.name} +
-
- Submarine Cable{detail.is_planned ? ' (Planned)' : ''} -
-
- -
- 길이 - {detail.length || '-'} -
-
- 개통 - {detail.rfs || '-'} -
- {detail.owners && ( -
- 운영사 - - {detail.owners} + {detail.is_planned && ( + + Planned -
- )} - {detail.suppliers && ( -
- 공급사 - {detail.suppliers} -
- )} + )} +
+ {/* ── Info rows ── */} +
+ + + {detail.owners && } + {detail.suppliers && } +
+ + {/* ── Landing Points ── */} {landingCount > 0 && ( -
-
- Landing Points ({landingCount}) · {countries.length} countries +
+
+ Landing Points + + {landingCount}곳 · {countries.length}개국 +
{detail.landing_points.map((lp) => ( -
- {lp.country}{' '} - {lp.name} - {lp.is_tbd && TBD} +
+ + {lp.country} + + {lp.name} + {lp.is_tbd && ( + + TBD + + )}
))}
)} + {/* ── Notes ── */} {detail.notes && ( -
+
{detail.notes}
)} + {/* ── Link ── */} {detail.url && ( - ); } + +function InfoRow({ label, value, wrap }: { label: string; value: string | null; wrap?: boolean }) { + return ( +
+ {label} + + {value || '-'} + +
+ ); +} diff --git a/package-lock.json b/package-lock.json index 0aa3a3d..ff6a498 100644 --- a/package-lock.json +++ b/package-lock.json @@ -31,6 +31,7 @@ "dependencies": { "@deck.gl/aggregation-layers": "^9.2.7", "@deck.gl/core": "^9.2.7", + "@deck.gl/geo-layers": "^9.2.7", "@deck.gl/layers": "^9.2.7", "@deck.gl/mapbox": "^9.2.7", "@react-oauth/google": "^0.13.4", @@ -382,6 +383,57 @@ "mjolnir.js": "^3.0.0" } }, + "node_modules/@deck.gl/extensions": { + "version": "9.2.7", + "resolved": "https://registry.npmjs.org/@deck.gl/extensions/-/extensions-9.2.7.tgz", + "integrity": "sha512-jIsep2NByEimWlScqc/NLjpqWknLk5rd+uP8UAl7qI8CTInXV4KdzaYgujL+bE4lSV4Zlg0oMOAkbcviMKDLNw==", + "license": "MIT", + "peer": true, + "dependencies": { + "@luma.gl/constants": "^9.2.6", + "@luma.gl/shadertools": "^9.2.6", + "@math.gl/core": "^4.1.0" + }, + "peerDependencies": { + "@deck.gl/core": "~9.2.0", + "@luma.gl/core": "~9.2.6", + "@luma.gl/engine": "~9.2.6" + } + }, + "node_modules/@deck.gl/geo-layers": { + "version": "9.2.7", + "resolved": "https://registry.npmjs.org/@deck.gl/geo-layers/-/geo-layers-9.2.7.tgz", + "integrity": "sha512-DiEfsmWrW0EIM44FdwzdPAjUr8a8IPelpkt2fPvrgmaS0OSZFB1PkJWLmM3hoYO8mH0vuD0YL//m+Pvtw3bGSw==", + "license": "MIT", + "dependencies": { + "@loaders.gl/3d-tiles": "^4.3.4", + "@loaders.gl/gis": "^4.3.4", + "@loaders.gl/loader-utils": "^4.3.4", + "@loaders.gl/mvt": "^4.3.4", + "@loaders.gl/schema": "^4.3.4", + "@loaders.gl/terrain": "^4.3.4", + "@loaders.gl/tiles": "^4.3.4", + "@loaders.gl/wms": "^4.3.4", + "@luma.gl/gltf": "^9.2.6", + "@luma.gl/shadertools": "^9.2.6", + "@math.gl/core": "^4.1.0", + "@math.gl/culling": "^4.1.0", + "@math.gl/web-mercator": "^4.1.0", + "@types/geojson": "^7946.0.8", + "a5-js": "^0.5.0", + "h3-js": "^4.1.0", + "long": "^3.2.0" + }, + "peerDependencies": { + "@deck.gl/core": "~9.2.0", + "@deck.gl/extensions": "~9.2.0", + "@deck.gl/layers": "~9.2.0", + "@deck.gl/mesh-layers": "~9.2.0", + "@loaders.gl/core": "^4.3.4", + "@luma.gl/core": "~9.2.6", + "@luma.gl/engine": "~9.2.6" + } + }, "node_modules/@deck.gl/layers": { "version": "9.2.7", "resolved": "https://registry.npmjs.org/@deck.gl/layers/-/layers-9.2.7.tgz", @@ -421,6 +473,26 @@ "@math.gl/web-mercator": "^4.1.0" } }, + "node_modules/@deck.gl/mesh-layers": { + "version": "9.2.7", + "resolved": "https://registry.npmjs.org/@deck.gl/mesh-layers/-/mesh-layers-9.2.7.tgz", + "integrity": "sha512-EpWHJ3GaCXELCsYRlabvkXxtgLQwOZYU8YPOmlKUYf+/410B2D89oNGtJinRcfM1/T9TBelBS9CHMYsL1tv9cA==", + "license": "MIT", + "peer": true, + "dependencies": { + "@loaders.gl/gltf": "^4.3.4", + "@loaders.gl/schema": "^4.3.4", + "@luma.gl/gltf": "^9.2.6", + "@luma.gl/shadertools": "^9.2.6" + }, + "peerDependencies": { + "@deck.gl/core": "~9.2.0", + "@luma.gl/core": "~9.2.6", + "@luma.gl/engine": "~9.2.6", + "@luma.gl/gltf": "~9.2.6", + "@luma.gl/shadertools": "~9.2.6" + } + }, "node_modules/@esbuild/aix-ppc64": { "version": "0.27.3", "resolved": "https://registry.npmjs.org/@esbuild/aix-ppc64/-/aix-ppc64-0.27.3.tgz", @@ -1275,6 +1347,61 @@ "@jridgewell/sourcemap-codec": "^1.4.14" } }, + "node_modules/@loaders.gl/3d-tiles": { + "version": "4.3.4", + "resolved": "https://registry.npmjs.org/@loaders.gl/3d-tiles/-/3d-tiles-4.3.4.tgz", + "integrity": "sha512-JQ3y3p/KlZP7lfobwON5t7H9WinXEYTvuo3SRQM8TBKhM+koEYZhvI2GwzoXx54MbBbY+s3fm1dq5UAAmaTsZw==", + "license": "MIT", + "dependencies": { + "@loaders.gl/compression": "4.3.4", + "@loaders.gl/crypto": "4.3.4", + "@loaders.gl/draco": "4.3.4", + "@loaders.gl/gltf": "4.3.4", + "@loaders.gl/images": "4.3.4", + "@loaders.gl/loader-utils": "4.3.4", + "@loaders.gl/math": "4.3.4", + "@loaders.gl/tiles": "4.3.4", + "@loaders.gl/zip": "4.3.4", + "@math.gl/core": "^4.1.0", + "@math.gl/culling": "^4.1.0", + "@math.gl/geospatial": "^4.1.0", + "@probe.gl/log": "^4.0.4", + "long": "^5.2.1" + }, + "peerDependencies": { + "@loaders.gl/core": "^4.3.0" + } + }, + "node_modules/@loaders.gl/3d-tiles/node_modules/long": { + "version": "5.3.2", + "resolved": "https://registry.npmjs.org/long/-/long-5.3.2.tgz", + "integrity": "sha512-mNAgZ1GmyNhD7AuqnTG3/VQ26o760+ZYBPKjPvugO8+nLbYfX6TVpJPseBvopbdY+qpZ/lKUnmEc1LeZYS3QAA==", + "license": "Apache-2.0" + }, + "node_modules/@loaders.gl/compression": { + "version": "4.3.4", + "resolved": "https://registry.npmjs.org/@loaders.gl/compression/-/compression-4.3.4.tgz", + "integrity": "sha512-+o+5JqL9Sx8UCwdc2MTtjQiUHYQGJALHbYY/3CT+b9g/Emzwzez2Ggk9U9waRfdHiBCzEgRBivpWZEOAtkimXQ==", + "license": "MIT", + "dependencies": { + "@loaders.gl/loader-utils": "4.3.4", + "@loaders.gl/worker-utils": "4.3.4", + "@types/brotli": "^1.3.0", + "@types/pako": "^1.0.1", + "fflate": "0.7.4", + "lzo-wasm": "^0.0.4", + "pako": "1.0.11", + "snappyjs": "^0.6.1" + }, + "optionalDependencies": { + "brotli": "^1.3.2", + "lz4js": "^0.2.0", + "zstd-codec": "^0.1" + }, + "peerDependencies": { + "@loaders.gl/core": "^4.3.0" + } + }, "node_modules/@loaders.gl/core": { "version": "4.3.4", "resolved": "https://registry.npmjs.org/@loaders.gl/core/-/core-4.3.4.tgz", @@ -1288,6 +1415,96 @@ "@probe.gl/log": "^4.0.2" } }, + "node_modules/@loaders.gl/crypto": { + "version": "4.3.4", + "resolved": "https://registry.npmjs.org/@loaders.gl/crypto/-/crypto-4.3.4.tgz", + "integrity": "sha512-3VS5FgB44nLOlAB9Q82VOQnT1IltwfRa1miE0mpHCe1prYu1M/dMnEyynusbrsp+eDs3EKbxpguIS9HUsFu5dQ==", + "license": "MIT", + "dependencies": { + "@loaders.gl/loader-utils": "4.3.4", + "@loaders.gl/worker-utils": "4.3.4", + "@types/crypto-js": "^4.0.2" + }, + "peerDependencies": { + "@loaders.gl/core": "^4.3.0" + } + }, + "node_modules/@loaders.gl/draco": { + "version": "4.3.4", + "resolved": "https://registry.npmjs.org/@loaders.gl/draco/-/draco-4.3.4.tgz", + "integrity": "sha512-4Lx0rKmYENGspvcgV5XDpFD9o+NamXoazSSl9Oa3pjVVjo+HJuzCgrxTQYD/3JvRrolW/QRehZeWD/L/cEC6mw==", + "license": "MIT", + "dependencies": { + "@loaders.gl/loader-utils": "4.3.4", + "@loaders.gl/schema": "4.3.4", + "@loaders.gl/worker-utils": "4.3.4", + "draco3d": "1.5.7" + }, + "peerDependencies": { + "@loaders.gl/core": "^4.3.0" + } + }, + "node_modules/@loaders.gl/gis": { + "version": "4.3.4", + "resolved": "https://registry.npmjs.org/@loaders.gl/gis/-/gis-4.3.4.tgz", + "integrity": "sha512-8xub38lSWW7+ZXWuUcggk7agRHJUy6RdipLNKZ90eE0ZzLNGDstGD1qiBwkvqH0AkG+uz4B7Kkiptyl7w2Oa6g==", + "license": "MIT", + "dependencies": { + "@loaders.gl/loader-utils": "4.3.4", + "@loaders.gl/schema": "4.3.4", + "@mapbox/vector-tile": "^1.3.1", + "@math.gl/polygon": "^4.1.0", + "pbf": "^3.2.1" + }, + "peerDependencies": { + "@loaders.gl/core": "^4.3.0" + } + }, + "node_modules/@loaders.gl/gis/node_modules/@mapbox/point-geometry": { + "version": "0.1.0", + "resolved": "https://registry.npmjs.org/@mapbox/point-geometry/-/point-geometry-0.1.0.tgz", + "integrity": "sha512-6j56HdLTwWGO0fJPlrZtdU/B13q8Uwmo18Ck2GnGgN9PCFyKTZ3UbXeEdRFh18i9XQ92eH2VdtpJHpBD3aripQ==", + "license": "ISC" + }, + "node_modules/@loaders.gl/gis/node_modules/@mapbox/vector-tile": { + "version": "1.3.1", + "resolved": "https://registry.npmjs.org/@mapbox/vector-tile/-/vector-tile-1.3.1.tgz", + "integrity": "sha512-MCEddb8u44/xfQ3oD+Srl/tNcQoqTw3goGk2oLsrFxOTc3dUp+kAnby3PvAeeBYSMSjSPD1nd1AJA6W49WnoUw==", + "license": "BSD-3-Clause", + "dependencies": { + "@mapbox/point-geometry": "~0.1.0" + } + }, + "node_modules/@loaders.gl/gis/node_modules/pbf": { + "version": "3.3.0", + "resolved": "https://registry.npmjs.org/pbf/-/pbf-3.3.0.tgz", + "integrity": "sha512-XDF38WCH3z5OV/OVa8GKUNtLAyneuzbCisx7QUCF8Q6Nutx0WnJrQe5O+kOtBlLfRNUws98Y58Lblp+NJG5T4Q==", + "license": "BSD-3-Clause", + "dependencies": { + "ieee754": "^1.1.12", + "resolve-protobuf-schema": "^2.1.0" + }, + "bin": { + "pbf": "bin/pbf" + } + }, + "node_modules/@loaders.gl/gltf": { + "version": "4.3.4", + "resolved": "https://registry.npmjs.org/@loaders.gl/gltf/-/gltf-4.3.4.tgz", + "integrity": "sha512-EiUTiLGMfukLd9W98wMpKmw+hVRhQ0dJ37wdlXK98XPeGGB+zTQxCcQY+/BaMhsSpYt/OOJleHhTfwNr8RgzRg==", + "license": "MIT", + "dependencies": { + "@loaders.gl/draco": "4.3.4", + "@loaders.gl/images": "4.3.4", + "@loaders.gl/loader-utils": "4.3.4", + "@loaders.gl/schema": "4.3.4", + "@loaders.gl/textures": "4.3.4", + "@math.gl/core": "^4.1.0" + }, + "peerDependencies": { + "@loaders.gl/core": "^4.3.0" + } + }, "node_modules/@loaders.gl/images": { "version": "4.3.4", "resolved": "https://registry.npmjs.org/@loaders.gl/images/-/images-4.3.4.tgz", @@ -1315,6 +1532,51 @@ "@loaders.gl/core": "^4.3.0" } }, + "node_modules/@loaders.gl/math": { + "version": "4.3.4", + "resolved": "https://registry.npmjs.org/@loaders.gl/math/-/math-4.3.4.tgz", + "integrity": "sha512-UJrlHys1fp9EUO4UMnqTCqvKvUjJVCbYZ2qAKD7tdGzHJYT8w/nsP7f/ZOYFc//JlfC3nq+5ogvmdpq2pyu3TA==", + "license": "MIT", + "dependencies": { + "@loaders.gl/images": "4.3.4", + "@loaders.gl/loader-utils": "4.3.4", + "@math.gl/core": "^4.1.0" + }, + "peerDependencies": { + "@loaders.gl/core": "^4.3.0" + } + }, + "node_modules/@loaders.gl/mvt": { + "version": "4.3.4", + "resolved": "https://registry.npmjs.org/@loaders.gl/mvt/-/mvt-4.3.4.tgz", + "integrity": "sha512-9DrJX8RQf14htNtxsPIYvTso5dUce9WaJCWCIY/79KYE80Be6dhcEYMknxBS4w3+PAuImaAe66S5xo9B7Erm5A==", + "license": "MIT", + "dependencies": { + "@loaders.gl/gis": "4.3.4", + "@loaders.gl/images": "4.3.4", + "@loaders.gl/loader-utils": "4.3.4", + "@loaders.gl/schema": "4.3.4", + "@math.gl/polygon": "^4.1.0", + "@probe.gl/stats": "^4.0.0", + "pbf": "^3.2.1" + }, + "peerDependencies": { + "@loaders.gl/core": "^4.3.0" + } + }, + "node_modules/@loaders.gl/mvt/node_modules/pbf": { + "version": "3.3.0", + "resolved": "https://registry.npmjs.org/pbf/-/pbf-3.3.0.tgz", + "integrity": "sha512-XDF38WCH3z5OV/OVa8GKUNtLAyneuzbCisx7QUCF8Q6Nutx0WnJrQe5O+kOtBlLfRNUws98Y58Lblp+NJG5T4Q==", + "license": "BSD-3-Clause", + "dependencies": { + "ieee754": "^1.1.12", + "resolve-protobuf-schema": "^2.1.0" + }, + "bin": { + "pbf": "bin/pbf" + } + }, "node_modules/@loaders.gl/schema": { "version": "4.3.4", "resolved": "https://registry.npmjs.org/@loaders.gl/schema/-/schema-4.3.4.tgz", @@ -1327,6 +1589,74 @@ "@loaders.gl/core": "^4.3.0" } }, + "node_modules/@loaders.gl/terrain": { + "version": "4.3.4", + "resolved": "https://registry.npmjs.org/@loaders.gl/terrain/-/terrain-4.3.4.tgz", + "integrity": "sha512-JszbRJGnxL5Fh82uA2U8HgjlsIpzYoCNNjy3cFsgCaxi4/dvjz3BkLlBilR7JlbX8Ka+zlb4GAbDDChiXLMJ/g==", + "license": "MIT", + "dependencies": { + "@loaders.gl/images": "4.3.4", + "@loaders.gl/loader-utils": "4.3.4", + "@loaders.gl/schema": "4.3.4", + "@mapbox/martini": "^0.2.0" + }, + "peerDependencies": { + "@loaders.gl/core": "^4.3.0" + } + }, + "node_modules/@loaders.gl/textures": { + "version": "4.3.4", + "resolved": "https://registry.npmjs.org/@loaders.gl/textures/-/textures-4.3.4.tgz", + "integrity": "sha512-arWIDjlE7JaDS6v9by7juLfxPGGnjT9JjleaXx3wq/PTp+psLOpGUywHXm38BNECos3MFEQK3/GFShWI+/dWPw==", + "license": "MIT", + "dependencies": { + "@loaders.gl/images": "4.3.4", + "@loaders.gl/loader-utils": "4.3.4", + "@loaders.gl/schema": "4.3.4", + "@loaders.gl/worker-utils": "4.3.4", + "@math.gl/types": "^4.1.0", + "ktx-parse": "^0.7.0", + "texture-compressor": "^1.0.2" + }, + "peerDependencies": { + "@loaders.gl/core": "^4.3.0" + } + }, + "node_modules/@loaders.gl/tiles": { + "version": "4.3.4", + "resolved": "https://registry.npmjs.org/@loaders.gl/tiles/-/tiles-4.3.4.tgz", + "integrity": "sha512-oC0zJfyvGox6Ag9ABF8fxOkx9yEFVyzTa9ryHXl2BqLiQoR1v3p+0tIJcEbh5cnzHfoTZzUis1TEAZluPRsHBQ==", + "license": "MIT", + "dependencies": { + "@loaders.gl/loader-utils": "4.3.4", + "@loaders.gl/math": "4.3.4", + "@math.gl/core": "^4.1.0", + "@math.gl/culling": "^4.1.0", + "@math.gl/geospatial": "^4.1.0", + "@math.gl/web-mercator": "^4.1.0", + "@probe.gl/stats": "^4.0.2" + }, + "peerDependencies": { + "@loaders.gl/core": "^4.3.0" + } + }, + "node_modules/@loaders.gl/wms": { + "version": "4.3.4", + "resolved": "https://registry.npmjs.org/@loaders.gl/wms/-/wms-4.3.4.tgz", + "integrity": "sha512-yXF0wuYzJUdzAJQrhLIua6DnjOiBJusaY1j8gpvuH1VYs3mzvWlIRuZKeUd9mduQZKK88H2IzHZbj2RGOauq4w==", + "license": "MIT", + "dependencies": { + "@loaders.gl/images": "4.3.4", + "@loaders.gl/loader-utils": "4.3.4", + "@loaders.gl/schema": "4.3.4", + "@loaders.gl/xml": "4.3.4", + "@turf/rewind": "^5.1.5", + "deep-strict-equal": "^0.2.0" + }, + "peerDependencies": { + "@loaders.gl/core": "^4.3.0" + } + }, "node_modules/@loaders.gl/worker-utils": { "version": "4.3.4", "resolved": "https://registry.npmjs.org/@loaders.gl/worker-utils/-/worker-utils-4.3.4.tgz", @@ -1336,11 +1666,42 @@ "@loaders.gl/core": "^4.3.0" } }, + "node_modules/@loaders.gl/xml": { + "version": "4.3.4", + "resolved": "https://registry.npmjs.org/@loaders.gl/xml/-/xml-4.3.4.tgz", + "integrity": "sha512-p+y/KskajsvyM3a01BwUgjons/j/dUhniqd5y1p6keLOuwoHlY/TfTKd+XluqfyP14vFrdAHCZTnFCWLblN10w==", + "license": "MIT", + "dependencies": { + "@loaders.gl/loader-utils": "4.3.4", + "@loaders.gl/schema": "4.3.4", + "fast-xml-parser": "^4.2.5" + }, + "peerDependencies": { + "@loaders.gl/core": "^4.3.0" + } + }, + "node_modules/@loaders.gl/zip": { + "version": "4.3.4", + "resolved": "https://registry.npmjs.org/@loaders.gl/zip/-/zip-4.3.4.tgz", + "integrity": "sha512-bHY4XdKYJm3vl9087GMoxnUqSURwTxPPh6DlAGOmz6X9Mp3JyWuA2gk3tQ1UIuInfjXKph3WAUfGe6XRIs1sfw==", + "license": "MIT", + "dependencies": { + "@loaders.gl/compression": "4.3.4", + "@loaders.gl/crypto": "4.3.4", + "@loaders.gl/loader-utils": "4.3.4", + "jszip": "^3.1.5", + "md5": "^2.3.0" + }, + "peerDependencies": { + "@loaders.gl/core": "^4.3.0" + } + }, "node_modules/@luma.gl/constants": { "version": "9.2.6", "resolved": "https://registry.npmjs.org/@luma.gl/constants/-/constants-9.2.6.tgz", "integrity": "sha512-rvFFrJuSm5JIWbDHFuR4Q2s4eudO3Ggsv0TsGKn9eqvO7bBiPm/ANugHredvh3KviEyYuMZZxtfJvBdr3kzldg==", - "license": "MIT" + "license": "MIT", + "peer": true }, "node_modules/@luma.gl/core": { "version": "9.2.6", @@ -1373,6 +1734,24 @@ "@luma.gl/shadertools": "~9.2.0" } }, + "node_modules/@luma.gl/gltf": { + "version": "9.2.6", + "resolved": "https://registry.npmjs.org/@luma.gl/gltf/-/gltf-9.2.6.tgz", + "integrity": "sha512-is3YkiGsWqWTmwldMz6PRaIUleufQfUKYjJTKpsF5RS1OnN+xdAO0mJq5qJTtOQpppWAU0VrmDFEVZ6R3qvm0A==", + "license": "MIT", + "dependencies": { + "@loaders.gl/core": "^4.2.0", + "@loaders.gl/gltf": "^4.2.0", + "@loaders.gl/textures": "^4.2.0", + "@math.gl/core": "^4.1.0" + }, + "peerDependencies": { + "@luma.gl/constants": "~9.2.0", + "@luma.gl/core": "~9.2.0", + "@luma.gl/engine": "~9.2.0", + "@luma.gl/shadertools": "~9.2.0" + } + }, "node_modules/@luma.gl/shadertools": { "version": "9.2.6", "resolved": "https://registry.npmjs.org/@luma.gl/shadertools/-/shadertools-9.2.6.tgz", @@ -1423,6 +1802,12 @@ "node": ">= 0.6" } }, + "node_modules/@mapbox/martini": { + "version": "0.2.0", + "resolved": "https://registry.npmjs.org/@mapbox/martini/-/martini-0.2.0.tgz", + "integrity": "sha512-7hFhtkb0KTLEls+TRw/rWayq5EeHtTaErgm/NskVoXmtgAQu/9D299aeyj6mzAR/6XUnYRp2lU+4IcrYRFjVsQ==", + "license": "ISC" + }, "node_modules/@mapbox/point-geometry": { "version": "1.1.0", "resolved": "https://registry.npmjs.org/@mapbox/point-geometry/-/point-geometry-1.1.0.tgz", @@ -1520,6 +1905,26 @@ "@math.gl/types": "4.1.0" } }, + "node_modules/@math.gl/culling": { + "version": "4.1.0", + "resolved": "https://registry.npmjs.org/@math.gl/culling/-/culling-4.1.0.tgz", + "integrity": "sha512-jFmjFEACnP9kVl8qhZxFNhCyd47qPfSVmSvvjR0/dIL6R9oD5zhR1ub2gN16eKDO/UM7JF9OHKU3EBIfeR7gtg==", + "license": "MIT", + "dependencies": { + "@math.gl/core": "4.1.0", + "@math.gl/types": "4.1.0" + } + }, + "node_modules/@math.gl/geospatial": { + "version": "4.1.0", + "resolved": "https://registry.npmjs.org/@math.gl/geospatial/-/geospatial-4.1.0.tgz", + "integrity": "sha512-BzsUhpVvnmleyYF6qdqJIip6FtIzJmnWuPTGhlBuPzh7VBHLonCFSPtQpbkRuoyAlbSyaGXcVt6p6lm9eK2vtg==", + "license": "MIT", + "dependencies": { + "@math.gl/core": "4.1.0", + "@math.gl/types": "4.1.0" + } + }, "node_modules/@math.gl/polygon": { "version": "4.1.0", "resolved": "https://registry.npmjs.org/@math.gl/polygon/-/polygon-4.1.0.tgz", @@ -1944,6 +2349,62 @@ "win32" ] }, + "node_modules/@turf/boolean-clockwise": { + "version": "5.1.5", + "resolved": "https://registry.npmjs.org/@turf/boolean-clockwise/-/boolean-clockwise-5.1.5.tgz", + "integrity": "sha512-FqbmEEOJ4rU4/2t7FKx0HUWmjFEVqR+NJrFP7ymGSjja2SQ7Q91nnBihGuT+yuHHl6ElMjQ3ttsB/eTmyCycxA==", + "license": "MIT", + "dependencies": { + "@turf/helpers": "^5.1.5", + "@turf/invariant": "^5.1.5" + } + }, + "node_modules/@turf/clone": { + "version": "5.1.5", + "resolved": "https://registry.npmjs.org/@turf/clone/-/clone-5.1.5.tgz", + "integrity": "sha512-//pITsQ8xUdcQ9pVb4JqXiSqG4dos5Q9N4sYFoWghX21tfOV2dhc5TGqYOhnHrQS7RiKQL1vQ48kIK34gQ5oRg==", + "license": "MIT", + "dependencies": { + "@turf/helpers": "^5.1.5" + } + }, + "node_modules/@turf/helpers": { + "version": "5.1.5", + "resolved": "https://registry.npmjs.org/@turf/helpers/-/helpers-5.1.5.tgz", + "integrity": "sha512-/lF+JR+qNDHZ8bF9d+Cp58nxtZWJ3sqFe6n3u3Vpj+/0cqkjk4nXKYBSY0azm+GIYB5mWKxUXvuP/m0ZnKj1bw==", + "license": "MIT" + }, + "node_modules/@turf/invariant": { + "version": "5.2.0", + "resolved": "https://registry.npmjs.org/@turf/invariant/-/invariant-5.2.0.tgz", + "integrity": "sha512-28RCBGvCYsajVkw2EydpzLdcYyhSA77LovuOvgCJplJWaNVyJYH6BOR3HR9w50MEkPqb/Vc/jdo6I6ermlRtQA==", + "license": "MIT", + "dependencies": { + "@turf/helpers": "^5.1.5" + } + }, + "node_modules/@turf/meta": { + "version": "5.2.0", + "resolved": "https://registry.npmjs.org/@turf/meta/-/meta-5.2.0.tgz", + "integrity": "sha512-ZjQ3Ii62X9FjnK4hhdsbT+64AYRpaI8XMBMcyftEOGSmPMUVnkbvuv3C9geuElAXfQU7Zk1oWGOcrGOD9zr78Q==", + "license": "MIT", + "dependencies": { + "@turf/helpers": "^5.1.5" + } + }, + "node_modules/@turf/rewind": { + "version": "5.1.5", + "resolved": "https://registry.npmjs.org/@turf/rewind/-/rewind-5.1.5.tgz", + "integrity": "sha512-Gdem7JXNu+G4hMllQHXRFRihJl3+pNl7qY+l4qhQFxq+hiU1cQoVFnyoleIqWKIrdK/i2YubaSwc3SCM7N5mMw==", + "license": "MIT", + "dependencies": { + "@turf/boolean-clockwise": "^5.1.5", + "@turf/clone": "^5.1.5", + "@turf/helpers": "^5.1.5", + "@turf/invariant": "^5.1.5", + "@turf/meta": "^5.1.5" + } + }, "node_modules/@types/babel__core": { "version": "7.20.5", "resolved": "https://registry.npmjs.org/@types/babel__core/-/babel__core-7.20.5.tgz", @@ -1989,6 +2450,21 @@ "@babel/types": "^7.28.2" } }, + "node_modules/@types/brotli": { + "version": "1.3.4", + "resolved": "https://registry.npmjs.org/@types/brotli/-/brotli-1.3.4.tgz", + "integrity": "sha512-cKYjgaS2DMdCKF7R0F5cgx1nfBYObN2ihIuPGQ4/dlIY6RpV7OWNwe9L8V4tTVKL2eZqOkNM9FM/rgTvLf4oXw==", + "license": "MIT", + "dependencies": { + "@types/node": "*" + } + }, + "node_modules/@types/crypto-js": { + "version": "4.2.2", + "resolved": "https://registry.npmjs.org/@types/crypto-js/-/crypto-js-4.2.2.tgz", + "integrity": "sha512-sDOLlVbHhXpAUAL0YHDUUwDZf3iN4Bwi4W6a0W0b+QcAezUbRtH4FVb+9J4h+XFPW7l/gQ9F8qC7P+Ec4k8QVQ==", + "license": "MIT" + }, "node_modules/@types/estree": { "version": "1.0.8", "resolved": "https://registry.npmjs.org/@types/estree/-/estree-1.0.8.tgz", @@ -2013,7 +2489,6 @@ "version": "24.10.13", "resolved": "https://registry.npmjs.org/@types/node/-/node-24.10.13.tgz", "integrity": "sha512-oH72nZRfDv9lADUBSo104Aq7gPHpQZc4BTx38r9xf9pg5LfP6EzSyH2n7qFmmxRQXh7YlUXODcYsg6PuTDSxGg==", - "dev": true, "license": "MIT", "dependencies": { "undici-types": "~7.16.0" @@ -2025,6 +2500,12 @@ "integrity": "sha512-ieXiYmgSRXUDeOntE1InxjWyvEelZGP63M+cGuquuRLuIKKT1osnkXjxev9B7d1nXSug5vpunx+gNlbVxMlC9A==", "license": "MIT" }, + "node_modules/@types/pako": { + "version": "1.0.7", + "resolved": "https://registry.npmjs.org/@types/pako/-/pako-1.0.7.tgz", + "integrity": "sha512-YBtzT2ztNF6R/9+UXj2wTGFnC9NklAnASt3sC0h2m1bbH7G6FyBIkt4AN8ThZpNfxUo1b2iMVO0UawiJymEt8A==", + "license": "MIT" + }, "node_modules/@types/react": { "version": "19.2.14", "resolved": "https://registry.npmjs.org/@types/react/-/react-19.2.14.tgz", @@ -2354,6 +2835,15 @@ "resolved": "apps/web", "link": true }, + "node_modules/a5-js": { + "version": "0.5.0", + "resolved": "https://registry.npmjs.org/a5-js/-/a5-js-0.5.0.tgz", + "integrity": "sha512-VAw19sWdYadhdovb0ViOIi1SdKx6H6LwcGMRFKwMfgL5gcmL/1fKJHfgsNgNaJ7xC/eEyjs6VK+VVd4N0a+peg==", + "license": "Apache-2.0", + "dependencies": { + "gl-matrix": "^3.4.3" + } + }, "node_modules/abstract-logging": { "version": "2.0.1", "resolved": "https://registry.npmjs.org/abstract-logging/-/abstract-logging-2.0.1.tgz", @@ -2509,6 +2999,27 @@ "dev": true, "license": "MIT" }, + "node_modules/base64-js": { + "version": "1.5.1", + "resolved": "https://registry.npmjs.org/base64-js/-/base64-js-1.5.1.tgz", + "integrity": "sha512-AKpaYlHn8t4SVbOHCy+b5+KKgvR4vrsD8vbvrbiQJps7fKDTkjkDry6ji0rUJjC0kzbNePLwzxq8iypo41qeWA==", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/feross" + }, + { + "type": "patreon", + "url": "https://www.patreon.com/feross" + }, + { + "type": "consulting", + "url": "https://feross.org/support" + } + ], + "license": "MIT", + "optional": true + }, "node_modules/baseline-browser-mapping": { "version": "2.9.19", "resolved": "https://registry.npmjs.org/baseline-browser-mapping/-/baseline-browser-mapping-2.9.19.tgz", @@ -2530,6 +3041,16 @@ "concat-map": "0.0.1" } }, + "node_modules/brotli": { + "version": "1.3.3", + "resolved": "https://registry.npmjs.org/brotli/-/brotli-1.3.3.tgz", + "integrity": "sha512-oTKjJdShmDuGW94SyyaoQvAjf30dZaHnjJ8uAF+u2/vGJkJbJPJAT1gDiOJP5v1Zb6f9KEyW/1HpuaWIXtGHPg==", + "license": "MIT", + "optional": true, + "dependencies": { + "base64-js": "^1.1.2" + } + }, "node_modules/browserslist": { "version": "4.28.1", "resolved": "https://registry.npmjs.org/browserslist/-/browserslist-4.28.1.tgz", @@ -2565,6 +3086,15 @@ "node": "^6 || ^7 || ^8 || ^9 || ^10 || ^11 || ^12 || >=13.7" } }, + "node_modules/buf-compare": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/buf-compare/-/buf-compare-1.0.1.tgz", + "integrity": "sha512-Bvx4xH00qweepGc43xFvMs5BKASXTbHaHm6+kDYIK9p/4iFwjATQkmPKHQSgJZzKbAymhztRbXUf1Nqhzl73/Q==", + "license": "MIT", + "engines": { + "node": ">=0.10.0" + } + }, "node_modules/callsites": { "version": "3.1.0", "resolved": "https://registry.npmjs.org/callsites/-/callsites-3.1.0.tgz", @@ -2627,6 +3157,15 @@ "url": "https://github.com/chalk/chalk?sponsor=1" } }, + "node_modules/charenc": { + "version": "0.0.2", + "resolved": "https://registry.npmjs.org/charenc/-/charenc-0.0.2.tgz", + "integrity": "sha512-yrLQ/yVUFXkzg7EDQsPieE/53+0RlaWTs+wBrvW36cyilJ2SaDWfl4Yj7MtLTXleV9uEKefbAGUPv2/iWSooRA==", + "license": "BSD-3-Clause", + "engines": { + "node": "*" + } + }, "node_modules/codepage": { "version": "1.15.0", "resolved": "https://registry.npmjs.org/codepage/-/codepage-1.15.0.tgz", @@ -2684,6 +3223,25 @@ "url": "https://opencollective.com/express" } }, + "node_modules/core-assert": { + "version": "0.2.1", + "resolved": "https://registry.npmjs.org/core-assert/-/core-assert-0.2.1.tgz", + "integrity": "sha512-IG97qShIP+nrJCXMCgkNZgH7jZQ4n8RpPyPeXX++T6avR/KhLhgLiHKoEn5Rc1KjfycSfA9DMa6m+4C4eguHhw==", + "license": "MIT", + "dependencies": { + "buf-compare": "^1.0.0", + "is-error": "^2.2.0" + }, + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/core-util-is": { + "version": "1.0.3", + "resolved": "https://registry.npmjs.org/core-util-is/-/core-util-is-1.0.3.tgz", + "integrity": "sha512-ZQBvi1DcpJ4GDqanjucZ2Hj3wEO5pZDS89BWbkcrvdxksJorwUDDZamX9ldFkp9aw2lmBDLgkObEA4DWNJ9FYQ==", + "license": "MIT" + }, "node_modules/crc-32": { "version": "1.2.2", "resolved": "https://registry.npmjs.org/crc-32/-/crc-32-1.2.2.tgz", @@ -2712,6 +3270,15 @@ "node": ">= 8" } }, + "node_modules/crypt": { + "version": "0.0.2", + "resolved": "https://registry.npmjs.org/crypt/-/crypt-0.0.2.tgz", + "integrity": "sha512-mCxBlsHFYh9C+HVpiEacem8FEBnMXgU9gy4zmNC+SXAZNB/1idgp/aulFJ4FgCi7GPEVbfyng092GqL2k2rmow==", + "license": "BSD-3-Clause", + "engines": { + "node": "*" + } + }, "node_modules/csstype": { "version": "3.2.3", "resolved": "https://registry.npmjs.org/csstype/-/csstype-3.2.3.tgz", @@ -2750,6 +3317,18 @@ "dev": true, "license": "MIT" }, + "node_modules/deep-strict-equal": { + "version": "0.2.0", + "resolved": "https://registry.npmjs.org/deep-strict-equal/-/deep-strict-equal-0.2.0.tgz", + "integrity": "sha512-3daSWyvZ/zwJvuMGlzG1O+Ow0YSadGfb3jsh9xoCutv2tWyB9dA4YvR9L9/fSdDZa2dByYQe+TqapSGUrjnkoA==", + "license": "MIT", + "dependencies": { + "core-assert": "^0.2.0" + }, + "engines": { + "node": ">=0.10.0" + } + }, "node_modules/dequal": { "version": "2.0.3", "resolved": "https://registry.npmjs.org/dequal/-/dequal-2.0.3.tgz", @@ -2759,6 +3338,12 @@ "node": ">=6" } }, + "node_modules/draco3d": { + "version": "1.5.7", + "resolved": "https://registry.npmjs.org/draco3d/-/draco3d-1.5.7.tgz", + "integrity": "sha512-m6WCKt/erDXcw+70IJXnG7M3awwQPAsZvJGX5zY7beBqpELw6RDGkYVU0W43AFxye4pDZ5i2Lbyc/NNGqwjUVQ==", + "license": "Apache-2.0" + }, "node_modules/earcut": { "version": "2.2.4", "resolved": "https://registry.npmjs.org/earcut/-/earcut-2.2.4.tgz", @@ -3119,6 +3704,24 @@ ], "license": "BSD-3-Clause" }, + "node_modules/fast-xml-parser": { + "version": "4.5.3", + "resolved": "https://registry.npmjs.org/fast-xml-parser/-/fast-xml-parser-4.5.3.tgz", + "integrity": "sha512-RKihhV+SHsIUGXObeVy9AXiBbFwkVk7Syp8XgwN5U3JV416+Gwp/GO9i0JYKmikykgz/UHRrrV4ROuZEo/T0ig==", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/NaturalIntelligence" + } + ], + "license": "MIT", + "dependencies": { + "strnum": "^1.1.1" + }, + "bin": { + "fxparser": "src/cli/cli.js" + } + }, "node_modules/fastify": { "version": "5.7.4", "resolved": "https://registry.npmjs.org/fastify/-/fastify-5.7.4.tgz", @@ -3207,6 +3810,12 @@ } } }, + "node_modules/fflate": { + "version": "0.7.4", + "resolved": "https://registry.npmjs.org/fflate/-/fflate-0.7.4.tgz", + "integrity": "sha512-5u2V/CDW15QM1XbbgS+0DfPxVB+jUKhWEKuuFuHncbk3tEEqzmoXL+2KyOFuKGqOnmdIy0/davWF1CkuwtibCw==", + "license": "MIT" + }, "node_modules/file-entry-cache": { "version": "8.0.0", "resolved": "https://registry.npmjs.org/file-entry-cache/-/file-entry-cache-8.0.0.tgz", @@ -3364,6 +3973,17 @@ "url": "https://github.com/sponsors/sindresorhus" } }, + "node_modules/h3-js": { + "version": "4.4.0", + "resolved": "https://registry.npmjs.org/h3-js/-/h3-js-4.4.0.tgz", + "integrity": "sha512-DvJh07MhGgY2KcC4OeZc8SSyA+ZXpdvoh6uCzGpoKvWtZxJB+g6VXXC1+eWYkaMIsLz7J/ErhOalHCpcs1KYog==", + "license": "Apache-2.0", + "engines": { + "node": ">=4", + "npm": ">=3", + "yarn": ">=1.3.0" + } + }, "node_modules/has-flag": { "version": "4.0.0", "resolved": "https://registry.npmjs.org/has-flag/-/has-flag-4.0.0.tgz", @@ -3391,6 +4011,26 @@ "hermes-estree": "0.25.1" } }, + "node_modules/ieee754": { + "version": "1.2.1", + "resolved": "https://registry.npmjs.org/ieee754/-/ieee754-1.2.1.tgz", + "integrity": "sha512-dcyqhDvX1C46lXZcVqCpK+FtMRQVdIMN6/Df5js2zouUsqG7I6sFxitIC+7KYK29KdXOLHdu9zL4sFnoVQnqaA==", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/feross" + }, + { + "type": "patreon", + "url": "https://www.patreon.com/feross" + }, + { + "type": "consulting", + "url": "https://feross.org/support" + } + ], + "license": "BSD-3-Clause" + }, "node_modules/ignore": { "version": "5.3.2", "resolved": "https://registry.npmjs.org/ignore/-/ignore-5.3.2.tgz", @@ -3401,6 +4041,24 @@ "node": ">= 4" } }, + "node_modules/image-size": { + "version": "0.7.5", + "resolved": "https://registry.npmjs.org/image-size/-/image-size-0.7.5.tgz", + "integrity": "sha512-Hiyv+mXHfFEP7LzUL/llg9RwFxxY+o9N3JVLIeG5E7iFIFAalxvRU9UZthBdYDEVnzHMgjnKJPPpay5BWf1g9g==", + "license": "MIT", + "bin": { + "image-size": "bin/image-size.js" + }, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/immediate": { + "version": "3.0.6", + "resolved": "https://registry.npmjs.org/immediate/-/immediate-3.0.6.tgz", + "integrity": "sha512-XXOFtyqDjNDAQxVfYxuF7g9Il/IbWmmlQg2MYKOH8ExIT1qg6xc4zyS3HaEEATgs1btfzxq15ciUiY7gjSXRGQ==", + "license": "MIT" + }, "node_modules/import-fresh": { "version": "3.3.1", "resolved": "https://registry.npmjs.org/import-fresh/-/import-fresh-3.3.1.tgz", @@ -3428,6 +4086,12 @@ "node": ">=0.8.19" } }, + "node_modules/inherits": { + "version": "2.0.4", + "resolved": "https://registry.npmjs.org/inherits/-/inherits-2.0.4.tgz", + "integrity": "sha512-k/vGaX4/Yla3WzyMCvTQOXYeIHvqOKtnqBduzTHpzpQZzAskKMhZ2K+EnBiSM9zGSoIFeMpXKxa4dYeZIQqewQ==", + "license": "ISC" + }, "node_modules/ipaddr.js": { "version": "2.3.0", "resolved": "https://registry.npmjs.org/ipaddr.js/-/ipaddr.js-2.3.0.tgz", @@ -3437,6 +4101,18 @@ "node": ">= 10" } }, + "node_modules/is-buffer": { + "version": "1.1.6", + "resolved": "https://registry.npmjs.org/is-buffer/-/is-buffer-1.1.6.tgz", + "integrity": "sha512-NcdALwpXkTm5Zvvbk7owOUSvVvBKDgKP5/ewfXEznmQFfs4ZRmanOeKBTjRVjka3QFoN6XJ+9F3USqfHqTaU5w==", + "license": "MIT" + }, + "node_modules/is-error": { + "version": "2.2.2", + "resolved": "https://registry.npmjs.org/is-error/-/is-error-2.2.2.tgz", + "integrity": "sha512-IOQqts/aHWbiisY5DuPJQ0gcbvaLFCa7fBa9xoLfxBZvQ+ZI/Zh9xoI7Gk+G64N0FdK4AbibytHht2tWgpJWLg==", + "license": "MIT" + }, "node_modules/is-extglob": { "version": "2.1.1", "resolved": "https://registry.npmjs.org/is-extglob/-/is-extglob-2.1.1.tgz", @@ -3460,6 +4136,12 @@ "node": ">=0.10.0" } }, + "node_modules/isarray": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/isarray/-/isarray-1.0.0.tgz", + "integrity": "sha512-VLghIWNM6ELQzo7zwmcg0NmTVyWKYjvIeM83yjp0wRDTmUnrM678fQbcKBo6n2CJEF0szoG//ytg+TKla89ALQ==", + "license": "MIT" + }, "node_modules/isexe": { "version": "2.0.0", "resolved": "https://registry.npmjs.org/isexe/-/isexe-2.0.0.tgz", @@ -3559,6 +4241,18 @@ "node": ">=6" } }, + "node_modules/jszip": { + "version": "3.10.1", + "resolved": "https://registry.npmjs.org/jszip/-/jszip-3.10.1.tgz", + "integrity": "sha512-xXDvecyTpGLrqFrvkrUSoxxfJI5AH7U8zxxtVclpsUtMCq4JQ290LY8AW5c7Ggnr/Y/oK+bQMbqK2qmtk3pN4g==", + "license": "(MIT OR GPL-3.0-or-later)", + "dependencies": { + "lie": "~3.3.0", + "pako": "~1.0.2", + "readable-stream": "~2.3.6", + "setimmediate": "^1.0.5" + } + }, "node_modules/kdbush": { "version": "4.0.2", "resolved": "https://registry.npmjs.org/kdbush/-/kdbush-4.0.2.tgz", @@ -3575,6 +4269,12 @@ "json-buffer": "3.0.1" } }, + "node_modules/ktx-parse": { + "version": "0.7.1", + "resolved": "https://registry.npmjs.org/ktx-parse/-/ktx-parse-0.7.1.tgz", + "integrity": "sha512-FeA3g56ksdFNwjXJJsc1CCc7co+AJYDp6ipIp878zZ2bU8kWROatLYf39TQEd4/XRSUvBXovQ8gaVKWPXsCLEQ==", + "license": "MIT" + }, "node_modules/levn": { "version": "0.4.1", "resolved": "https://registry.npmjs.org/levn/-/levn-0.4.1.tgz", @@ -3589,6 +4289,15 @@ "node": ">= 0.8.0" } }, + "node_modules/lie": { + "version": "3.3.0", + "resolved": "https://registry.npmjs.org/lie/-/lie-3.3.0.tgz", + "integrity": "sha512-UaiMJzeWRlEujzAuw5LokY1L5ecNQYZKfmyZ9L7wDHb/p5etKaxXhohBcrw0EYby+G/NA52vRSN4N39dxHAIwQ==", + "license": "MIT", + "dependencies": { + "immediate": "~3.0.5" + } + }, "node_modules/light-my-request": { "version": "6.6.0", "resolved": "https://registry.npmjs.org/light-my-request/-/light-my-request-6.6.0.tgz", @@ -3649,6 +4358,15 @@ "dev": true, "license": "MIT" }, + "node_modules/long": { + "version": "3.2.0", + "resolved": "https://registry.npmjs.org/long/-/long-3.2.0.tgz", + "integrity": "sha512-ZYvPPOMqUwPoDsbJaR10iQJYnMuZhRTvHYl62ErLIEX7RgFlziSBUUvrt3OVfc47QlHHpzPZYP17g3Fv7oeJkg==", + "license": "Apache-2.0", + "engines": { + "node": ">=0.6" + } + }, "node_modules/lru-cache": { "version": "5.1.1", "resolved": "https://registry.npmjs.org/lru-cache/-/lru-cache-5.1.1.tgz", @@ -3659,6 +4377,19 @@ "yallist": "^3.0.2" } }, + "node_modules/lz4js": { + "version": "0.2.0", + "resolved": "https://registry.npmjs.org/lz4js/-/lz4js-0.2.0.tgz", + "integrity": "sha512-gY2Ia9Lm7Ep8qMiuGRhvUq0Q7qUereeldZPP1PMEJxPtEWHJLqw9pgX68oHajBH0nzJK4MaZEA/YNV3jT8u8Bg==", + "license": "ISC", + "optional": true + }, + "node_modules/lzo-wasm": { + "version": "0.0.4", + "resolved": "https://registry.npmjs.org/lzo-wasm/-/lzo-wasm-0.0.4.tgz", + "integrity": "sha512-VKlnoJRFrB8SdJhlVKvW5vI1gGwcZ+mvChEXcSX6r2xDNc/Q2FD9esfBmGCuPZdrJ1feO+YcVFd2PTk0c137Gw==", + "license": "BSD-2-Clause" + }, "node_modules/maplibre-gl": { "version": "5.18.0", "resolved": "https://registry.npmjs.org/maplibre-gl/-/maplibre-gl-5.18.0.tgz", @@ -3702,6 +4433,17 @@ "integrity": "sha512-X7hshQbLyMJ/3RPhyObLARM2sNxxmRALLKx1+NVFFnQ9gKzmCrxm9+uLIAdBcvc8FNLpctqlQ2V6AE92Ol9UDQ==", "license": "ISC" }, + "node_modules/md5": { + "version": "2.3.0", + "resolved": "https://registry.npmjs.org/md5/-/md5-2.3.0.tgz", + "integrity": "sha512-T1GITYmFaKuO91vxyoQMFETst+O71VUPEU3ze5GNzDm0OWdP8v1ziTaAEPUr/3kLsY3Sftgz242A1SetQiDL7g==", + "license": "BSD-3-Clause", + "dependencies": { + "charenc": "0.0.2", + "crypt": "0.0.2", + "is-buffer": "~1.1.6" + } + }, "node_modules/minimatch": { "version": "3.1.2", "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-3.1.2.tgz", @@ -3835,6 +4577,12 @@ "url": "https://github.com/sponsors/sindresorhus" } }, + "node_modules/pako": { + "version": "1.0.11", + "resolved": "https://registry.npmjs.org/pako/-/pako-1.0.11.tgz", + "integrity": "sha512-4hLB8Py4zZce5s4yd9XzopqwVv/yGNhV1Bl8NTmCq1763HeK2+EwVTv+leGeL13Dnh2wfbqowVPXCIO0z4taYw==", + "license": "(MIT AND Zlib)" + }, "node_modules/parent-module": { "version": "1.0.1", "resolved": "https://registry.npmjs.org/parent-module/-/parent-module-1.0.1.tgz", @@ -3983,6 +4731,12 @@ "node": ">= 0.8.0" } }, + "node_modules/process-nextick-args": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/process-nextick-args/-/process-nextick-args-2.0.1.tgz", + "integrity": "sha512-3ouUOpQhtgrbOa17J7+uxOTpITYWaGP7/AhoR3+A+/1e9skrzelGi/dXzEYyvbxubEF6Wn2ypscTKiKJFFn1ag==", + "license": "MIT" + }, "node_modules/process-warning": { "version": "5.0.0", "resolved": "https://registry.npmjs.org/process-warning/-/process-warning-5.0.0.tgz", @@ -4082,6 +4836,21 @@ } } }, + "node_modules/readable-stream": { + "version": "2.3.8", + "resolved": "https://registry.npmjs.org/readable-stream/-/readable-stream-2.3.8.tgz", + "integrity": "sha512-8p0AUk4XODgIewSi0l8Epjs+EVnWiK7NoDIEGU0HhE7+ZyY8D1IMY7odu5lRrFXGg71L15KG8QrPmum45RTtdA==", + "license": "MIT", + "dependencies": { + "core-util-is": "~1.0.0", + "inherits": "~2.0.3", + "isarray": "~1.0.0", + "process-nextick-args": "~2.0.0", + "safe-buffer": "~5.1.1", + "string_decoder": "~1.1.1", + "util-deprecate": "~1.0.1" + } + }, "node_modules/real-require": { "version": "0.2.0", "resolved": "https://registry.npmjs.org/real-require/-/real-require-0.2.0.tgz", @@ -4205,6 +4974,12 @@ "integrity": "sha512-PdhdWy89SiZogBLaw42zdeqtRJ//zFd2PgQavcICDUgJT5oW10QCRKbJ6bg4r0/UY2M6BWd5tkxuGFRvCkgfHQ==", "license": "BSD-3-Clause" }, + "node_modules/safe-buffer": { + "version": "5.1.2", + "resolved": "https://registry.npmjs.org/safe-buffer/-/safe-buffer-5.1.2.tgz", + "integrity": "sha512-Gd2UZBJDkXlY7GbJxfsE8/nvKkUEU1G38c1siN6QP6a9PT9MmHB8GnpscSmMJSoF8LOIrt8ud/wPtojys4G6+g==", + "license": "MIT" + }, "node_modules/safe-regex2": { "version": "5.0.0", "resolved": "https://registry.npmjs.org/safe-regex2/-/safe-regex2-5.0.0.tgz", @@ -4271,6 +5046,12 @@ "integrity": "sha512-oeM1lpU/UvhTxw+g3cIfxXHyJRc/uidd3yK1P242gzHds0udQBYzs3y8j4gCCW+ZJ7ad0yctld8RYO+bdurlvw==", "license": "MIT" }, + "node_modules/setimmediate": { + "version": "1.0.5", + "resolved": "https://registry.npmjs.org/setimmediate/-/setimmediate-1.0.5.tgz", + "integrity": "sha512-MATJdZp8sLqDl/68LfQmbP8zKPLQNV6BIZoIgrscFDQ+RsvK/BxeDQOgyxKKoh0y/8h3BqVFnCqQ/gd+reiIXA==", + "license": "MIT" + }, "node_modules/shebang-command": { "version": "2.0.0", "resolved": "https://registry.npmjs.org/shebang-command/-/shebang-command-2.0.0.tgz", @@ -4294,6 +5075,12 @@ "node": ">=8" } }, + "node_modules/snappyjs": { + "version": "0.6.1", + "resolved": "https://registry.npmjs.org/snappyjs/-/snappyjs-0.6.1.tgz", + "integrity": "sha512-YIK6I2lsH072UE0aOFxxY1dPDCS43I5ktqHpeAsuLNYWkE5pGxRGWfDM4/vSUfNzXjC1Ivzt3qx31PCLmc9yqg==", + "license": "MIT" + }, "node_modules/sonic-boom": { "version": "4.2.1", "resolved": "https://registry.npmjs.org/sonic-boom/-/sonic-boom-4.2.1.tgz", @@ -4322,6 +5109,12 @@ "node": ">= 10.x" } }, + "node_modules/sprintf-js": { + "version": "1.0.3", + "resolved": "https://registry.npmjs.org/sprintf-js/-/sprintf-js-1.0.3.tgz", + "integrity": "sha512-D9cPgkvLlV3t3IzL0D0YLvGA9Ahk4PcvVwUbN0dSGr1aP0Nrt4AEnTUbuGvquEC0mA64Gqt1fzirlRs5ibXx8g==", + "license": "BSD-3-Clause" + }, "node_modules/ssf": { "version": "0.11.2", "resolved": "https://registry.npmjs.org/ssf/-/ssf-0.11.2.tgz", @@ -4335,6 +5128,15 @@ "node": ">=0.8" } }, + "node_modules/string_decoder": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/string_decoder/-/string_decoder-1.1.1.tgz", + "integrity": "sha512-n/ShnvDi6FHbbVfviro+WojiFzv+s8MPMHBczVePfUpDJLwoLT0ht1l4YwBCbi8pJAveEEdnkHyPyTP/mzRfwg==", + "license": "MIT", + "dependencies": { + "safe-buffer": "~5.1.0" + } + }, "node_modules/strip-json-comments": { "version": "3.1.1", "resolved": "https://registry.npmjs.org/strip-json-comments/-/strip-json-comments-3.1.1.tgz", @@ -4348,6 +5150,18 @@ "url": "https://github.com/sponsors/sindresorhus" } }, + "node_modules/strnum": { + "version": "1.1.2", + "resolved": "https://registry.npmjs.org/strnum/-/strnum-1.1.2.tgz", + "integrity": "sha512-vrN+B7DBIoTTZjnPNewwhx6cBA/H+IS7rfW68n7XxC1y7uoiGQBxaKzqucGUgavX15dJgiGztLJ8vxuEzwqBdA==", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/NaturalIntelligence" + } + ], + "license": "MIT" + }, "node_modules/supercluster": { "version": "8.0.1", "resolved": "https://registry.npmjs.org/supercluster/-/supercluster-8.0.1.tgz", @@ -4370,6 +5184,28 @@ "node": ">=8" } }, + "node_modules/texture-compressor": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/texture-compressor/-/texture-compressor-1.0.2.tgz", + "integrity": "sha512-dStVgoaQ11mA5htJ+RzZ51ZxIZqNOgWKAIvtjLrW1AliQQLCmrDqNzQZ8Jh91YealQ95DXt4MEduLzJmbs6lig==", + "license": "MIT", + "dependencies": { + "argparse": "^1.0.10", + "image-size": "^0.7.4" + }, + "bin": { + "texture-compressor": "bin/texture-compressor.js" + } + }, + "node_modules/texture-compressor/node_modules/argparse": { + "version": "1.0.10", + "resolved": "https://registry.npmjs.org/argparse/-/argparse-1.0.10.tgz", + "integrity": "sha512-o5Roy6tNG4SL/FOkCAN6RzjiakZS25RLYFrcMttJqbdd8BWrnA+fGz57iN5Pb06pvBGvl5gQ0B48dJlslXvoTg==", + "license": "MIT", + "dependencies": { + "sprintf-js": "~1.0.2" + } + }, "node_modules/thread-stream": { "version": "4.0.0", "resolved": "https://registry.npmjs.org/thread-stream/-/thread-stream-4.0.0.tgz", @@ -4503,7 +5339,6 @@ "version": "7.16.0", "resolved": "https://registry.npmjs.org/undici-types/-/undici-types-7.16.0.tgz", "integrity": "sha512-Zz+aZWSj8LE6zoxD+xrjh4VfkIG8Ya6LvYkZqtUQGJPZjYl53ypCaUwWqo7eI0x66KBGeRo+mlBEkMSeSZ38Nw==", - "dev": true, "license": "MIT" }, "node_modules/update-browserslist-db": { @@ -4547,6 +5382,12 @@ "punycode": "^2.1.0" } }, + "node_modules/util-deprecate": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/util-deprecate/-/util-deprecate-1.0.2.tgz", + "integrity": "sha512-EPD5q1uXyFxJpCrLnCc1nHnq3gOa6DZBocAIiI2TaSCA7VCJ1UJDMagCzIkXNsUYfD1daK//LTEQ8xiIbrHtcw==", + "license": "MIT" + }, "node_modules/vite": { "version": "7.3.1", "resolved": "https://registry.npmjs.org/vite/-/vite-7.3.1.tgz", @@ -4740,6 +5581,13 @@ "peerDependencies": { "zod": "^3.25.0 || ^4.0.0" } + }, + "node_modules/zstd-codec": { + "version": "0.1.5", + "resolved": "https://registry.npmjs.org/zstd-codec/-/zstd-codec-0.1.5.tgz", + "integrity": "sha512-v3fyjpK8S/dpY/X5WxqTK3IoCnp/ZOLxn144GZVlNUjtwAchzrVo03h+oMATFhCIiJ5KTr4V3vDQQYz4RU684g==", + "license": "MIT", + "optional": true } } }