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 { ALARM_BADGE, type LegacyAlarmKind } from '../../../features/legacyDashboard/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'; // ── Alarm pulse animation constants ── const ALARM_PULSE_R_MIN = 8; const ALARM_PULSE_R_MAX = 14; const ALARM_PULSE_R_HOVER_MIN = 12; const ALARM_PULSE_R_HOVER_MAX = 18; const ALARM_PULSE_PERIOD_MS = 1500; /** Globe 모드 선박 아이콘 레이어 (halo, outline, symbol, label, alarm pulse, alarm badge) */ 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; alarmMmsiMap?: Map; }, ) { const { projection, settings, shipData, overlays, legacyHits, selectedMmsi, isBaseHighlightedMmsi, mapSyncEpoch, onGlobeShipsReady, alarmMmsiMap, } = opts; const epochRef = useRef(-1); const breatheRafRef = useRef(0); const prevGeoJsonRef = useRef(null); const prevAlarmGeoJsonRef = useRef(null); // 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 alarmKind = alarmMmsiMap?.get(t.mmsi) ?? null; const baseName = legacy?.shipNameCn || legacy?.shipNameRoman || t.name || ''; const labelName = alarmKind ? `[${ALARM_BADGE[alarmKind].label}] ${baseName}` : baseName; 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); // 상호작용 스케일링 제거 — selected/highlighted는 feature-state로 처리 // hover overlay 레이어가 확대 + z-priority를 담당 const iconSize3 = clampNumber(0.35 * sizeScale, 0.25, 1.3); const iconSize7 = clampNumber(0.45 * sizeScale, 0.3, 1.45); const iconSize10 = clampNumber(0.58 * sizeScale, 0.35, 1.8); const iconSize14 = clampNumber(0.85 * sizeScale, 0.45, 2.6); const iconSize18 = clampNumber(2.5 * sizeScale, 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, iconSize7, iconSize10, iconSize14, iconSize18, sizeScale, permitted: legacy ? 1 : 0, code: legacy?.shipCode || '', alarmed: alarmKind ? 1 : 0, alarmKind: alarmKind ?? '', alarmBadgeLabel: alarmKind ? ALARM_BADGE[alarmKind].label : '', alarmBadgeColor: alarmKind ? ALARM_BADGE[alarmKind].color : '#000', }, }; }), }; }, [shipData, legacyHits, alarmMmsiMap]); // Alarm-only GeoJSON — separate source to avoid badge symbol re-placement // when the main ship source updates (position polling) const alarmGeoJson = useMemo((): GeoJSON.FeatureCollection => { if (!alarmMmsiMap || alarmMmsiMap.size === 0) { return { type: 'FeatureCollection', features: [] }; } return { type: 'FeatureCollection', features: shipData .filter((t) => alarmMmsiMap.has(t.mmsi)) .map((t) => { const alarmKind = alarmMmsiMap.get(t.mmsi)!; 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, alarmed: 1, alarmBadgeLabel: ALARM_BADGE[alarmKind].label, alarmBadgeColor: ALARM_BADGE[alarmKind].color, }, }; }), }; }, [shipData, alarmMmsiMap]); // 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 alarmSrcId = 'ships-globe-alarm-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'; const pulseId = 'ships-globe-alarm-pulse'; const badgeId = 'ships-globe-alarm-badge'; // 레이어를 제거하지 않고 visibility만 'none'으로 설정 // guardedSetVisibility로 현재 값과 동일하면 호출 생략 (style._changed 방지) const hide = () => { for (const id of [badgeId, labelId, symbolId, symbolLiteId, pulseId, 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, pulseId, symbolLiteId, symbolId, badgeId]) { 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과 무관하게 항상 준비됨) // 참조 동일성 기반 setData 스킵 — 위치 변경 없는 epoch/설정 변경 시 재전송 방지 const geojson = globeShipGeoJson; const geoJsonChanged = geojson !== prevGeoJsonRef.current; try { const existing = map.getSource(srcId) as GeoJSONSource | undefined; if (existing) { if (geoJsonChanged) existing.setData(geojson); } else { map.addSource(srcId, { type: 'geojson', data: geojson } as GeoJSONSourceSpecification); } prevGeoJsonRef.current = geojson; } catch (e) { console.warn('Ship source setup failed:', e); return; } // Alarm source — isolated from main source for stable badge rendering try { const existingAlarm = map.getSource(alarmSrcId) as GeoJSONSource | undefined; const alarmChanged = alarmGeoJson !== prevAlarmGeoJsonRef.current; if (existingAlarm) { if (alarmChanged) existingAlarm.setData(alarmGeoJson); } else { map.addSource(alarmSrcId, { type: 'geojson', data: alarmGeoJson } as GeoJSONSourceSpecification); } prevAlarmGeoJsonRef.current = alarmGeoJson; } catch (e) { console.warn('Alarm source setup failed:', e); } const before = undefined; let needReorder = false; const priorityFilter = [ 'any', ['==', ['to-number', ['get', 'permitted'], 0], 1], ['==', ['to-number', ['get', 'alarmed'], 0], 1], ] as unknown as unknown[]; const nonPriorityFilter = [ 'all', ['==', ['to-number', ['get', 'permitted'], 0], 0], ['==', ['to-number', ['get', 'alarmed'], 0], 0], ] as unknown as unknown[]; if (!map.getLayer(haloId)) { needReorder = true; try { map.addLayer( { id: haloId, type: 'circle', source: srcId, layout: { visibility, 'circle-sort-key': [ 'case', ['all', ['==', ['get', 'alarmed'], 1], ['==', ['get', 'permitted'], 1]], 112, ['==', ['get', 'permitted'], 1], 110, ['==', ['get', 'alarmed'], 1], 22, 20, ] as never, }, paint: { 'circle-radius': GLOBE_SHIP_CIRCLE_RADIUS_EXPR, 'circle-color': ['coalesce', ['get', 'shipColor'], '#64748b'] as never, 'circle-opacity': [ 'case', ['==', ['feature-state', 'selected'], 1], 0.38, ['==', ['feature-state', '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)) { needReorder = true; 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', ['==', ['feature-state', 'selected'], 1], 'rgba(14,234,255,0.95)', ['==', ['feature-state', 'highlighted'], 1], 'rgba(245,158,11,0.95)', ['==', ['get', 'permitted'], 1], GLOBE_OUTLINE_PERMITTED, GLOBE_OUTLINE_OTHER, ] as never, 'circle-stroke-width': [ 'case', ['==', ['feature-state', 'selected'], 1], 3.4, ['==', ['feature-state', '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', 'alarmed'], 1], ['==', ['get', 'permitted'], 1]], 122, ['==', ['get', 'permitted'], 1], 120, ['==', ['get', 'alarmed'], 1], 32, 30, ] as never, }, } as unknown as LayerSpecification, before, ); } catch (e) { console.warn('Ship outline layer add failed:', e); } } // Alarm pulse circle (above outline, below ship icons) // Uses separate alarm source for stable rendering if (!map.getLayer(pulseId)) { needReorder = true; try { map.addLayer( { id: pulseId, type: 'circle', source: alarmSrcId, filter: ['==', ['get', 'alarmed'], 1] as never, layout: { visibility }, paint: { 'circle-radius': ALARM_PULSE_R_MIN, 'circle-color': ['coalesce', ['get', 'alarmBadgeColor'], '#6b7280'] as never, 'circle-opacity': 0.35, 'circle-stroke-width': 0, }, } as unknown as LayerSpecification, before, ); } catch (e) { console.warn('Ship alarm pulse layer add failed:', e); } } if (!map.getLayer(symbolLiteId)) { needReorder = true; 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)) { needReorder = true; try { map.addLayer( { id: symbolId, type: 'symbol', source: srcId, filter: priorityFilter as never, layout: { visibility, 'symbol-sort-key': [ 'case', ['all', ['==', ['get', 'alarmed'], 1], ['==', ['get', 'permitted'], 1]], 132, ['==', ['get', 'permitted'], 1], 130, ['==', ['get', 'alarmed'], 1], 47, 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', ['==', ['feature-state', 'selected'], 1], 1, ['==', ['feature-state', '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'], '']], ''], ['==', ['get', 'permitted'], 1], ] as unknown as unknown[]; if (!map.getLayer(labelId)) { needReorder = true; 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', ['==', ['feature-state', 'selected'], 1], 'rgba(14,234,255,0.95)', ['==', ['feature-state', '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); } } // Alarm badge symbol (above labels) // Uses separate alarm source for stable rendering if (!map.getLayer(badgeId)) { needReorder = true; try { map.addLayer( { id: badgeId, type: 'symbol', source: alarmSrcId, filter: ['==', ['get', 'alarmed'], 1] as never, layout: { visibility, 'text-field': ['get', 'alarmBadgeLabel'] as never, 'text-font': ['Noto Sans Regular', 'Open Sans Regular'], 'text-size': 11, 'text-allow-overlap': true, 'text-ignore-placement': true, 'text-anchor': 'center', }, paint: { 'text-color': '#ffffff', 'text-halo-color': ['coalesce', ['get', 'alarmBadgeColor'], '#6b7280'] as never, 'text-halo-width': 6, 'text-translate': [12, -12], }, } as unknown as LayerSpecification, undefined, ); } catch (e) { console.warn('Ship alarm badge layer add failed:', e); } } // 레이어가 준비되었음을 알림 (projection과 무관 — 토글 버튼 활성화용) onGlobeShipsReady?.(true); // needReorder: 새 레이어가 생성된 경우에만 reorder 호출 // 매 AIS poll마다 28개 moveLayer → style._changed 방지 if (projection === 'globe' && needReorder) { reorderGlobeFeatureLayers(); } kickRepaint(map); }; const stop = onMapStyleReady(map, ensure); return () => { stop(); }; }, [ projection, settings.showShips, overlays.shipLabels, globeShipGeoJson, alarmGeoJson, mapSyncEpoch, reorderGlobeFeatureLayers, onGlobeShipsReady, ]); // Feature-state로 상호작용 상태(selected/highlighted) 즉시 반영 — setData 없이 useEffect(() => { const map = mapRef.current; if (!map || projection !== 'globe' || projectionBusyRef.current) return; if (!map.isStyleLoaded() || !map.getSource('ships-globe-src')) return; const raf = requestAnimationFrame(() => { if (!map.isStyleLoaded()) return; const src = 'ships-globe-src'; const alarmSrc = 'ships-globe-alarm-src'; for (const t of shipData) { if (!isFiniteNumber(t.mmsi)) continue; const id = Math.trunc(t.mmsi); const s = t.mmsi === selectedMmsi ? 1 : 0; const h = isBaseHighlightedMmsi(t.mmsi) ? 1 : 0; try { map.setFeatureState({ source: src, id }, { selected: s, highlighted: h }); } catch { /* ignore */ } } if (map.getSource(alarmSrc) && alarmMmsiMap) { for (const t of shipData) { if (!alarmMmsiMap.has(t.mmsi)) continue; const id = Math.trunc(t.mmsi); try { map.setFeatureState( { source: alarmSrc, id }, { selected: t.mmsi === selectedMmsi ? 1 : 0, highlighted: isBaseHighlightedMmsi(t.mmsi) ? 1 : 0 }, ); } catch { /* ignore */ } } } kickRepaint(map); }); return () => cancelAnimationFrame(raf); }, [projection, selectedMmsi, isBaseHighlightedMmsi, shipData, alarmMmsiMap]); // Alarm pulse breathing animation (rAF) useEffect(() => { const map = mapRef.current; if (!map || projection !== 'globe' || !alarmMmsiMap || alarmMmsiMap.size === 0) { if (breatheRafRef.current) cancelAnimationFrame(breatheRafRef.current); breatheRafRef.current = 0; return; } const animate = () => { if (!map.isStyleLoaded()) { breatheRafRef.current = requestAnimationFrame(animate); return; } const t = (Math.sin(Date.now() / ALARM_PULSE_PERIOD_MS * Math.PI * 2) + 1) / 2; const normalR = ALARM_PULSE_R_MIN + t * (ALARM_PULSE_R_MAX - ALARM_PULSE_R_MIN); const hoverR = ALARM_PULSE_R_HOVER_MIN + t * (ALARM_PULSE_R_HOVER_MAX - ALARM_PULSE_R_HOVER_MIN); try { if (map.getLayer('ships-globe-alarm-pulse')) { map.setPaintProperty('ships-globe-alarm-pulse', 'circle-radius', [ 'case', ['any', ['==', ['feature-state', 'highlighted'], 1], ['==', ['feature-state', 'selected'], 1]], hoverR, normalR, ] as never); } } catch { // ignore } breatheRafRef.current = requestAnimationFrame(animate); }; breatheRafRef.current = requestAnimationFrame(animate); return () => { if (breatheRafRef.current) cancelAnimationFrame(breatheRafRef.current); breatheRafRef.current = 0; }; }, [projection, alarmMmsiMap]); }