import { useEffect, 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 { Map3DSettings, MapProjectionId } from '../types'; import { GLOBE_ICON_HEADING_OFFSET_DEG, DEG2RAD, } from '../constants'; import { isFiniteNumber } from '../lib/setUtils'; import { GLOBE_SHIP_CIRCLE_RADIUS_EXPR } from '../lib/mlExpressions'; import { kickRepaint, onMapStyleReady } from '../lib/mapCore'; import { getDisplayHeading, getGlobeBaseShipColor } from '../lib/shipUtils'; import { ensureFallbackShipImage } from '../lib/globeShipIcon'; import { clampNumber } from '../lib/geometry'; import { guardedSetVisibility } from '../lib/layerHelpers'; /** Globe 호버 오버레이 + 클릭 선택 */ export function useGlobeShipHover( mapRef: MutableRefObject, projectionBusyRef: MutableRefObject, reorderGlobeFeatureLayers: () => void, opts: { projection: MapProjectionId; settings: Map3DSettings; shipLayerData: AisTarget[]; shipHoverOverlaySet: Set; legacyHits: Map | null | undefined; selectedMmsi: number | null; mapSyncEpoch: number; onSelectMmsi: (mmsi: number | null) => void; onToggleHighlightMmsi?: (mmsi: number) => void; targets: AisTarget[]; hasAuxiliarySelectModifier: (ev?: { shiftKey?: boolean; ctrlKey?: boolean; metaKey?: boolean } | null) => boolean; }, ) { const { projection, settings, shipLayerData, shipHoverOverlaySet, legacyHits, selectedMmsi, mapSyncEpoch, onSelectMmsi, onToggleHighlightMmsi, targets, hasAuxiliarySelectModifier, } = opts; const epochRef = useRef(-1); const hoverSignatureRef = useRef(''); // 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 (epochRef.current !== mapSyncEpoch) { epochRef.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 === hoverSignatureRef.current && hasHoverSource && hasHoverLayers) { return; } hoverSignatureRef.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 symbolLiteId = 'ships-globe-lite'; 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, symbolLiteId, 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]); }