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 = {