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'; import type { LegacyVesselInfo } from '../../../entities/legacyVessel/model/types'; import type { MapToggleState } from '../../../features/mapToggles/MapToggles'; import type { Map3DSettings, MapProjectionId } from '../types'; import { ANCHORED_SHIP_ICON_ID, GLOBE_ICON_HEADING_OFFSET_DEG, GLOBE_OUTLINE_PERMITTED, GLOBE_OUTLINE_OTHER, } from '../constants'; import { isFiniteNumber } from '../lib/setUtils'; import { GLOBE_SHIP_CIRCLE_RADIUS_EXPR } from '../lib/mlExpressions'; import { kickRepaint, onMapStyleReady } from '../lib/mapCore'; import { isAnchoredShip, getDisplayHeading, getGlobeBaseShipColor, } from '../lib/shipUtils'; import { buildFallbackGlobeAnchoredShipIcon, ensureFallbackShipImage, } from '../lib/globeShipIcon'; import { clampNumber } from '../lib/geometry'; import { guardedSetVisibility } from '../lib/layerHelpers'; /** Globe 모드 선박 아이콘 레이어 (halo, outline, symbol, label) */ export function useGlobeShipLayers( mapRef: MutableRefObject, projectionBusyRef: MutableRefObject, reorderGlobeFeatureLayers: () => void, opts: { projection: MapProjectionId; settings: Map3DSettings; shipData: AisTarget[]; overlays: MapToggleState; legacyHits: Map | null | undefined; selectedMmsi: number | null; isBaseHighlightedMmsi: (mmsi: number) => boolean; mapSyncEpoch: number; onGlobeShipsReady?: (ready: boolean) => void; }, ) { const { projection, settings, shipData, overlays, legacyHits, selectedMmsi, isBaseHighlightedMmsi, mapSyncEpoch, onGlobeShipsReady, } = opts; const epochRef = useRef(-1); // 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]); // Ships in globe mode useEffect(() => { const map = mapRef.current; if (!map) return; const imgId = 'ship-globe-icon'; const anchoredImgId = ANCHORED_SHIP_ICON_ID; const srcId = 'ships-globe-src'; const haloId = 'ships-globe-halo'; const outlineId = 'ships-globe-outline'; const symbolLiteId = 'ships-globe-lite'; const symbolId = 'ships-globe'; const labelId = 'ships-globe-label'; // 레이어를 제거하지 않고 visibility만 'none'으로 설정 // guardedSetVisibility로 현재 값과 동일하면 호출 생략 (style._changed 방지) const hide = () => { for (const id of [labelId, symbolId, symbolLiteId, outlineId, haloId]) { guardedSetVisibility(map, id, 'none'); } }; const ensureImage = () => { ensureFallbackShipImage(map, imgId); ensureFallbackShipImage(map, anchoredImgId, buildFallbackGlobeAnchoredShipIcon); if (map.hasImage(imgId) && map.hasImage(anchoredImgId)) return; kickRepaint(map); }; const ensure = () => { if (!settings.showShips) { hide(); onGlobeShipsReady?.(false); return; } // 빠른 visibility 토글 — projectionBusy 중에도 실행 // guardedSetVisibility로 값이 실제로 바뀔 때만 setLayoutProperty 호출 // → style._changed 방지 → 불필요한 symbol placement 재계산 방지 → 라벨 사라짐 방지 const visibility: 'visible' | 'none' = projection === 'globe' ? 'visible' : 'none'; const labelVisibility: 'visible' | 'none' = projection === 'globe' && overlays.shipLabels ? 'visible' : 'none'; if (map.getLayer(symbolId) || map.getLayer(symbolLiteId)) { const changed = map.getLayoutProperty(symbolId, 'visibility') !== visibility || map.getLayoutProperty(symbolLiteId, 'visibility') !== visibility; if (changed) { for (const id of [haloId, outlineId, symbolLiteId, symbolId]) { guardedSetVisibility(map, id, visibility); } if (projection === 'globe') kickRepaint(map); } guardedSetVisibility(map, labelId, labelVisibility); } // 데이터 업데이트는 projectionBusy 중에는 차단 if (projectionBusyRef.current) { // 레이어가 이미 존재하면 ready 상태 유지 if (map.getLayer(symbolId)) onGlobeShipsReady?.(true); return; } if (!map.isStyleLoaded()) return; if (epochRef.current !== mapSyncEpoch) { epochRef.current = mapSyncEpoch; } try { ensureImage(); } catch (e) { console.warn('Ship icon image setup failed:', e); } // 사전 계산된 GeoJSON 사용 (useMemo에서 projection과 무관하게 항상 준비됨) const geojson = globeShipGeoJson; try { const existing = map.getSource(srcId) as GeoJSONSource | undefined; if (existing) existing.setData(geojson); else map.addSource(srcId, { type: 'geojson', data: geojson } as GeoJSONSourceSpecification); } catch (e) { console.warn('Ship source setup failed:', e); return; } const before = undefined; const priorityFilter = [ 'any', ['==', ['to-number', ['get', 'permitted'], 0], 1], ['==', ['to-number', ['get', 'selected'], 0], 1], ['==', ['to-number', ['get', 'highlighted'], 0], 1], ] as unknown as unknown[]; const nonPriorityFilter = [ 'all', ['==', ['to-number', ['get', 'permitted'], 0], 0], ['==', ['to-number', ['get', 'selected'], 0], 0], ['==', ['to-number', ['get', 'highlighted'], 0], 0], ] as unknown as unknown[]; if (!map.getLayer(haloId)) { try { map.addLayer( { id: haloId, type: 'circle', source: srcId, layout: { visibility, '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, }, paint: { 'circle-radius': GLOBE_SHIP_CIRCLE_RADIUS_EXPR, 'circle-color': ['coalesce', ['get', 'shipColor'], '#64748b'] as never, 'circle-opacity': [ 'case', ['==', ['get', 'selected'], 1], 0.38, ['==', ['get', 'highlighted'], 1], 0.34, 0.16, ] as never, }, } as unknown as LayerSpecification, before, ); } catch (e) { console.warn('Ship halo layer add failed:', e); } } if (!map.getLayer(outlineId)) { try { map.addLayer( { id: outlineId, type: 'circle', source: srcId, paint: { 'circle-radius': GLOBE_SHIP_CIRCLE_RADIUS_EXPR, 'circle-color': 'rgba(0,0,0,0)', '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, 'circle-stroke-width': [ 'case', ['==', ['get', 'selected'], 1], 3.4, ['==', ['get', 'highlighted'], 1], 2.7, ['==', ['get', 'permitted'], 1], 1.8, 0.7, ] as never, 'circle-stroke-opacity': 0.85, }, layout: { visibility, '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, }, } as unknown as LayerSpecification, before, ); } catch (e) { console.warn('Ship outline layer add failed:', e); } } if (!map.getLayer(symbolLiteId)) { try { map.addLayer( { id: symbolLiteId, type: 'symbol', source: srcId, minzoom: 6.5, filter: nonPriorityFilter as never, layout: { visibility, 'symbol-sort-key': 40 as never, 'icon-image': [ 'case', ['==', ['to-number', ['get', 'isAnchored'], 0], 1], anchoredImgId, imgId, ] as never, 'icon-size': [ 'interpolate', ['linear'], ['zoom'], 6.5, ['*', ['to-number', ['get', 'iconSize7'], 0.45], 0.45], 8, ['*', ['to-number', ['get', 'iconSize7'], 0.45], 0.62], 10, ['*', ['to-number', ['get', 'iconSize10'], 0.58], 0.72], 14, ['*', ['to-number', ['get', 'iconSize14'], 0.85], 0.78], 18, ['*', ['to-number', ['get', 'iconSize18'], 2.5], 0.78], ] as unknown as number[], 'icon-allow-overlap': true, 'icon-ignore-placement': true, 'icon-anchor': 'center', 'icon-rotate': [ 'case', ['==', ['to-number', ['get', 'isAnchored'], 0], 1], 0, ['to-number', ['get', 'heading'], 0], ] as never, 'icon-rotation-alignment': 'map', 'icon-pitch-alignment': 'map', }, paint: { 'icon-color': ['coalesce', ['get', 'shipColor'], '#64748b'] as never, 'icon-opacity': [ 'interpolate', ['linear'], ['zoom'], 6.5, 0.16, 8, 0.34, 11, 0.54, 14, 0.68, ] as never, }, } as unknown as LayerSpecification, before, ); } catch (e) { console.warn('Ship lite symbol layer add failed:', e); } } if (!map.getLayer(symbolId)) { try { map.addLayer( { id: symbolId, type: 'symbol', source: srcId, filter: priorityFilter as never, layout: { visibility, '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, 'icon-image': [ 'case', ['==', ['to-number', ['get', 'isAnchored'], 0], 1], anchoredImgId, imgId, ] as never, 'icon-size': [ 'interpolate', ['linear'], ['zoom'], 3, ['to-number', ['get', 'iconSize3'], 0.35], 7, ['to-number', ['get', 'iconSize7'], 0.45], 10, ['to-number', ['get', 'iconSize10'], 0.58], 14, ['to-number', ['get', 'iconSize14'], 0.85], 18, ['to-number', ['get', 'iconSize18'], 2.5], ] as unknown as number[], 'icon-allow-overlap': true, 'icon-ignore-placement': true, 'icon-anchor': 'center', 'icon-rotate': [ 'case', ['==', ['to-number', ['get', 'isAnchored'], 0], 1], 0, ['to-number', ['get', 'heading'], 0], ] as never, 'icon-rotation-alignment': 'map', 'icon-pitch-alignment': 'map', }, paint: { 'icon-color': ['coalesce', ['get', 'shipColor'], '#64748b'] as never, 'icon-opacity': [ 'case', ['==', ['get', 'selected'], 1], 1, ['==', ['get', 'highlighted'], 1], 0.95, ['==', ['get', 'permitted'], 1], 0.93, 0.9, ] as never, }, } as unknown as LayerSpecification, before, ); } catch (e) { console.warn('Ship symbol layer add failed:', e); } } const labelFilter = [ 'all', ['!=', ['to-string', ['coalesce', ['get', 'labelName'], '']], ''], [ 'any', ['==', ['get', 'permitted'], 1], ['==', ['get', 'selected'], 1], ['==', ['get', 'highlighted'], 1], ], ] as unknown as unknown[]; if (!map.getLayer(labelId)) { try { map.addLayer( { id: labelId, type: 'symbol', source: srcId, minzoom: 7, filter: labelFilter as never, layout: { visibility: labelVisibility, 'symbol-placement': 'point', 'text-field': ['get', 'labelName'] as never, 'text-font': ['Noto Sans Regular', 'Open Sans Regular'], 'text-size': ['interpolate', ['linear'], ['zoom'], 7, 10, 10, 11, 12, 12, 14, 13] as never, 'text-anchor': 'top', 'text-offset': [0, 1.1], 'text-padding': 2, 'text-allow-overlap': false, 'text-ignore-placement': false, }, paint: { 'text-color': [ 'case', ['==', ['get', 'selected'], 1], 'rgba(14,234,255,0.95)', ['==', ['get', 'highlighted'], 1], 'rgba(245,158,11,0.95)', 'rgba(226,232,240,0.92)', ] as never, 'text-halo-color': 'rgba(2,6,23,0.85)', 'text-halo-width': 1.2, 'text-halo-blur': 0.8, }, } as unknown as LayerSpecification, undefined, ); } catch (e) { console.warn('Ship label layer add failed:', e); } } // 레이어가 준비되었음을 알림 (projection과 무관 — 토글 버튼 활성화용) onGlobeShipsReady?.(true); if (projection === 'globe') { reorderGlobeFeatureLayers(); } kickRepaint(map); }; const stop = onMapStyleReady(map, ensure); return () => { stop(); }; }, [ projection, settings.showShips, overlays.shipLabels, globeShipGeoJson, selectedMmsi, isBaseHighlightedMmsi, mapSyncEpoch, reorderGlobeFeatureLayers, onGlobeShipsReady, ]); }