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, DEG2RAD, } 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'; export function useGlobeShips( mapRef: MutableRefObject, projectionBusyRef: MutableRefObject, reorderGlobeFeatureLayers: () => void, opts: { projection: MapProjectionId; settings: Map3DSettings; shipData: AisTarget[]; shipHighlightSet: Set; shipHoverOverlaySet: Set; shipOverlayLayerData: AisTarget[]; shipLayerData: AisTarget[]; shipByMmsi: Map; mapSyncEpoch: number; onSelectMmsi: (mmsi: number | null) => void; onToggleHighlightMmsi?: (mmsi: number) => void; targets: AisTarget[]; overlays: MapToggleState; legacyHits: Map | null | undefined; 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 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; if (!map) return; const srcId = 'ship-labels-src'; const layerId = 'ship-labels'; const remove = () => { try { if (map.getLayer(layerId)) map.removeLayer(layerId); } catch { // ignore } try { if (map.getSource(srcId)) map.removeSource(srcId); } catch { // ignore } }; const ensure = () => { if (projectionBusyRef.current) return; if (!map.isStyleLoaded()) return; if (projection !== 'mercator' || !settings.showShips) { remove(); return; } const visibility = overlays.shipLabels ? 'visible' : 'none'; const features: GeoJSON.Feature[] = []; for (const t of shipData) { const legacy = legacyHits?.get(t.mmsi) ?? null; const isTarget = !!legacy; const isSelected = selectedMmsi != null && t.mmsi === selectedMmsi; const isPinnedHighlight = shipHighlightSet.has(t.mmsi); if (!isTarget && !isSelected && !isPinnedHighlight) continue; const labelName = (legacy?.shipNameCn || legacy?.shipNameRoman || t.name || '').trim(); if (!labelName) continue; features.push({ type: 'Feature', id: `ship-label-${t.mmsi}`, geometry: { type: 'Point', coordinates: [t.lon, t.lat] }, properties: { mmsi: t.mmsi, labelName, selected: isSelected ? 1 : 0, highlighted: isPinnedHighlight ? 1 : 0, permitted: isTarget ? 1 : 0, }, }); } const fc: GeoJSON.FeatureCollection = { type: 'FeatureCollection', features }; try { const existing = map.getSource(srcId) as GeoJSONSource | undefined; if (existing) existing.setData(fc); else map.addSource(srcId, { type: 'geojson', data: fc } as GeoJSONSourceSpecification); } catch (e) { console.warn('Ship label source setup failed:', e); return; } const filter = ['!=', ['to-string', ['coalesce', ['get', 'labelName'], '']], ''] as unknown as unknown[]; if (!map.getLayer(layerId)) { try { map.addLayer( { id: layerId, type: 'symbol', source: srcId, minzoom: 7, filter: filter as never, layout: { visibility, '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); } } else { try { map.setLayoutProperty(layerId, 'visibility', visibility); } catch { // ignore } } kickRepaint(map); }; const stop = onMapStyleReady(map, ensure); return () => { stop(); }; }, [ projection, settings.showShips, overlays.shipLabels, shipData, legacyHits, selectedMmsi, shipHighlightSet, mapSyncEpoch, ]); // 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 symbolId = 'ships-globe'; const labelId = 'ships-globe-label'; // 레이어를 제거하지 않고 visibility만 'none'으로 설정 // guardedSetVisibility로 현재 값과 동일하면 호출 생략 (style._changed 방지) const hide = () => { for (const id of [labelId, symbolId, 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)) { 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); } guardedSetVisibility(map, labelId, labelVisibility); } // 데이터 업데이트는 projectionBusy 중에는 차단 if (projectionBusyRef.current) { // 레이어가 이미 존재하면 ready 상태 유지 if (map.getLayer(symbolId)) onGlobeShipsReady?.(true); return; } if (!map.isStyleLoaded()) return; if (globeShipsEpochRef.current !== mapSyncEpoch) { globeShipsEpochRef.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; 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); } } // halo: data-driven expressions are static — visibility handled by fast toggle above 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); } } // outline: data-driven expressions are static — visibility handled by fast toggle if (!map.getLayer(symbolId)) { try { map.addLayer( { id: symbolId, type: 'symbol', source: srcId, 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', 'permitted'], 1], 1, ['==', ['get', 'selected'], 1], 0.86, ['==', ['get', 'highlighted'], 1], 0.82, 0.66, ] as never, }, } as unknown as LayerSpecification, before, ); } catch (e) { console.warn('Ship symbol layer add failed:', e); } } // symbol: data-driven expressions are static — visibility handled by fast toggle 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); } } // label: filter/text-field are static — visibility handled by fast toggle // 레이어가 준비되었음을 알림 (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, ]); // Globe hover overlay ships useEffect(() => { const map = mapRef.current; if (!map) return; const imgId = 'ship-globe-icon'; const srcId = 'ships-globe-hover-src'; const haloId = 'ships-globe-hover-halo'; const outlineId = 'ships-globe-hover-outline'; const symbolId = 'ships-globe-hover'; const hideHover = () => { for (const id of [symbolId, outlineId, haloId]) { guardedSetVisibility(map, id, 'none'); } }; const ensure = () => { if (projectionBusyRef.current) return; if (!map.isStyleLoaded()) return; if (projection !== 'globe' || !settings.showShips || shipHoverOverlaySet.size === 0) { hideHover(); return; } if (globeShipsEpochRef.current !== mapSyncEpoch) { globeShipsEpochRef.current = mapSyncEpoch; } ensureFallbackShipImage(map, imgId); if (!map.hasImage(imgId)) { return; } const hovered = shipLayerData.filter((t) => shipHoverOverlaySet.has(t.mmsi) && !!legacyHits?.has(t.mmsi)); if (hovered.length === 0) { hideHover(); return; } const hoverSignature = hovered .map((t) => `${t.mmsi}:${t.lon.toFixed(6)}:${t.lat.toFixed(6)}:${t.heading ?? 0}`) .join('|'); const hasHoverSource = map.getSource(srcId) != null; const hasHoverLayers = [symbolId, outlineId, haloId].every((id) => map.getLayer(id)); if (hoverSignature === globeHoverShipSignatureRef.current && hasHoverSource && hasHoverLayers) { return; } globeHoverShipSignatureRef.current = hoverSignature; const needReorder = !hasHoverSource || !hasHoverLayers; const hoverGeojson: GeoJSON.FeatureCollection = { type: 'FeatureCollection', features: hovered.map((t) => { const legacy = legacyHits?.get(t.mmsi) ?? null; const heading = getDisplayHeading({ cog: t.cog, heading: t.heading, offset: GLOBE_ICON_HEADING_OFFSET_DEG, }); 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 scale = selected ? 1.16 : 1.1; 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 || '', cog: heading, heading, sog: isFiniteNumber(t.sog) ? t.sog : 0, shipColor: getGlobeBaseShipColor({ legacy: legacy?.shipCode || null, sog: isFiniteNumber(t.sog) ? t.sog : null, }), iconSize3: clampNumber(0.35 * sizeScale * scale, 0.25, 1.45), iconSize7: clampNumber(0.45 * sizeScale * scale, 0.3, 1.7), iconSize10: clampNumber(0.58 * sizeScale * scale, 0.35, 2.1), iconSize14: clampNumber(0.85 * sizeScale * scale, 0.45, 3.0), iconSize18: clampNumber(2.5 * sizeScale * scale, 1.0, 7.0), selected: selected ? 1 : 0, permitted: legacy ? 1 : 0, }, }; }), }; try { const existing = map.getSource(srcId) as GeoJSONSource | undefined; if (existing) existing.setData(hoverGeojson); else map.addSource(srcId, { type: 'geojson', data: hoverGeojson } as GeoJSONSourceSpecification); } catch (e) { console.warn('Ship hover source setup failed:', e); return; } const before = undefined; if (!map.getLayer(haloId)) { try { map.addLayer( { id: haloId, type: 'circle', source: srcId, layout: { visibility: 'visible', 'circle-sort-key': [ 'case', ['==', ['get', 'selected'], 1], 120, ['==', ['get', 'permitted'], 1], 115, 110, ] as never, }, paint: { 'circle-radius': GLOBE_SHIP_CIRCLE_RADIUS_EXPR, 'circle-color': [ 'case', ['==', ['get', 'selected'], 1], 'rgba(14,234,255,1)', 'rgba(245,158,11,1)', ] as never, 'circle-opacity': 0.42, }, } as unknown as LayerSpecification, before, ); } catch (e) { console.warn('Ship hover halo layer add failed:', e); } } else { map.setLayoutProperty(haloId, 'visibility', 'visible'); } 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)', 'rgba(245,158,11,0.95)', ] as never, 'circle-stroke-width': [ 'case', ['==', ['get', 'selected'], 1], 3.8, 2.2, ] as never, 'circle-stroke-opacity': 0.9, }, layout: { visibility: 'visible', 'circle-sort-key': [ 'case', ['==', ['get', 'selected'], 1], 121, ['==', ['get', 'permitted'], 1], 116, 111, ] as never, }, } as unknown as LayerSpecification, before, ); } catch (e) { console.warn('Ship hover outline layer add failed:', e); } } else { map.setLayoutProperty(outlineId, 'visibility', 'visible'); } if (!map.getLayer(symbolId)) { try { map.addLayer( { id: symbolId, type: 'symbol', source: srcId, layout: { visibility: 'visible', 'symbol-sort-key': [ 'case', ['==', ['get', 'selected'], 1], 122, ['==', ['get', 'permitted'], 1], 117, 112, ] as never, 'icon-image': imgId, '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': ['to-number', ['get', 'heading'], 0], 'icon-rotation-alignment': 'map', 'icon-pitch-alignment': 'map', }, paint: { 'icon-color': ['coalesce', ['get', 'shipColor'], '#64748b'] as never, 'icon-opacity': 1, }, } as unknown as LayerSpecification, before, ); } catch (e) { console.warn('Ship hover symbol layer add failed:', e); } } else { map.setLayoutProperty(symbolId, 'visibility', 'visible'); } if (needReorder) { reorderGlobeFeatureLayers(); } kickRepaint(map); }; const stop = onMapStyleReady(map, ensure); return () => { stop(); }; }, [ projection, settings.showShips, shipLayerData, legacyHits, shipHoverOverlaySet, selectedMmsi, mapSyncEpoch, reorderGlobeFeatureLayers, ]); // Globe ship click selection useEffect(() => { const map = mapRef.current; if (!map) return; if (projection !== 'globe' || !settings.showShips) return; const symbolId = 'ships-globe'; const haloId = 'ships-globe-halo'; const outlineId = 'ships-globe-outline'; const clickedRadiusDeg2 = Math.pow(0.08, 2); const onClick = (e: maplibregl.MapMouseEvent) => { try { const layerIds = [symbolId, haloId, outlineId].filter((id) => map.getLayer(id)); let feats: unknown[] = []; if (layerIds.length > 0) { try { feats = map.queryRenderedFeatures(e.point, { layers: layerIds }) as unknown[]; } catch { feats = []; } } const f = feats?.[0]; const props = ((f as { properties?: Record } | undefined)?.properties || {}) as Record< string, unknown >; const mmsi = Number(props.mmsi); if (Number.isFinite(mmsi)) { if (hasAuxiliarySelectModifier(e as unknown as { shiftKey?: boolean; ctrlKey?: boolean; metaKey?: boolean })) { onToggleHighlightMmsi?.(mmsi); return; } onSelectMmsi(mmsi); return; } const clicked = { lat: e.lngLat.lat, lon: e.lngLat.lng }; const cosLat = Math.cos(clicked.lat * DEG2RAD); let bestMmsi: number | null = null; let bestD2 = Number.POSITIVE_INFINITY; for (const t of targets) { if (!isFiniteNumber(t.lat) || !isFiniteNumber(t.lon)) continue; const dLon = (clicked.lon - t.lon) * cosLat; const dLat = clicked.lat - t.lat; const d2 = dLon * dLon + dLat * dLat; if (d2 <= clickedRadiusDeg2 && d2 < bestD2) { bestD2 = d2; bestMmsi = t.mmsi; } } if (bestMmsi != null) { if (hasAuxiliarySelectModifier(e as unknown as { shiftKey?: boolean; ctrlKey?: boolean; metaKey?: boolean })) { onToggleHighlightMmsi?.(bestMmsi); return; } onSelectMmsi(bestMmsi); return; } } catch { // ignore } onSelectMmsi(null); }; map.on('click', onClick); return () => { try { map.off('click', onClick); } catch { // ignore } }; }, [projection, settings.showShips, onSelectMmsi, onToggleHighlightMmsi, mapSyncEpoch, targets]); }