From e2dc927ad2fcdae1988d3b4d476673f553226f00 Mon Sep 17 00:00:00 2001 From: htlee Date: Mon, 16 Feb 2026 23:35:03 +0900 Subject: [PATCH] =?UTF-8?q?refactor(map3d):=20useGlobeShips=20977=EC=A4=84?= =?UTF-8?q?=20=E2=86=92=20=EC=84=9C=EB=B8=8C=ED=9B=85=203+1=EA=B0=9C=20?= =?UTF-8?q?=EB=B6=84=EB=A6=AC?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - useGlobeShipLabels: Mercator 선명 라벨 - useGlobeShipLayers: Globe 선박 아이콘 레이어 + GeoJSON - useGlobeShipHover: Globe 호버 오버레이 + 클릭 선택 - useGlobeShips: 오케스트레이터 (기존 호출부 호환) Co-Authored-By: Claude Opus 4.6 --- .../widgets/map3d/hooks/useGlobeShipHover.ts | 369 +++++++ .../widgets/map3d/hooks/useGlobeShipLabels.ts | 164 +++ .../widgets/map3d/hooks/useGlobeShipLayers.ts | 501 +++++++++ .../src/widgets/map3d/hooks/useGlobeShips.ts | 984 +----------------- 4 files changed, 1075 insertions(+), 943 deletions(-) create mode 100644 apps/web/src/widgets/map3d/hooks/useGlobeShipHover.ts create mode 100644 apps/web/src/widgets/map3d/hooks/useGlobeShipLabels.ts create mode 100644 apps/web/src/widgets/map3d/hooks/useGlobeShipLayers.ts diff --git a/apps/web/src/widgets/map3d/hooks/useGlobeShipHover.ts b/apps/web/src/widgets/map3d/hooks/useGlobeShipHover.ts new file mode 100644 index 0000000..0c4c195 --- /dev/null +++ b/apps/web/src/widgets/map3d/hooks/useGlobeShipHover.ts @@ -0,0 +1,369 @@ +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]); +} diff --git a/apps/web/src/widgets/map3d/hooks/useGlobeShipLabels.ts b/apps/web/src/widgets/map3d/hooks/useGlobeShipLabels.ts new file mode 100644 index 0000000..7fbd70a --- /dev/null +++ b/apps/web/src/widgets/map3d/hooks/useGlobeShipLabels.ts @@ -0,0 +1,164 @@ +import { useEffect, 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 { kickRepaint, onMapStyleReady } from '../lib/mapCore'; + +/** Mercator 모드 선명 라벨 (허가 선박 + 선택/하이라이트) */ +export function useGlobeShipLabels( + mapRef: MutableRefObject, + projectionBusyRef: MutableRefObject, + opts: { + projection: MapProjectionId; + settings: Map3DSettings; + shipData: AisTarget[]; + shipHighlightSet: Set; + overlays: MapToggleState; + legacyHits: Map | null | undefined; + selectedMmsi: number | null; + mapSyncEpoch: number; + }, +) { + const { + projection, settings, shipData, shipHighlightSet, + overlays, legacyHits, selectedMmsi, mapSyncEpoch, + } = opts; + + 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, + ]); +} diff --git a/apps/web/src/widgets/map3d/hooks/useGlobeShipLayers.ts b/apps/web/src/widgets/map3d/hooks/useGlobeShipLayers.ts new file mode 100644 index 0000000..ece38f3 --- /dev/null +++ b/apps/web/src/widgets/map3d/hooks/useGlobeShipLayers.ts @@ -0,0 +1,501 @@ +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, + ]); +} diff --git a/apps/web/src/widgets/map3d/hooks/useGlobeShips.ts b/apps/web/src/widgets/map3d/hooks/useGlobeShips.ts index 8cbc4ee..d0105c4 100644 --- a/apps/web/src/widgets/map3d/hooks/useGlobeShips.ts +++ b/apps/web/src/widgets/map3d/hooks/useGlobeShips.ts @@ -1,31 +1,12 @@ -import { useEffect, useMemo, useRef, type MutableRefObject } from 'react'; +import 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'; +import { useGlobeShipLabels } from './useGlobeShipLabels'; +import { useGlobeShipLayers } from './useGlobeShipLayers'; +import { useGlobeShipHover } from './useGlobeShipHover'; export function useGlobeShips( mapRef: MutableRefObject, @@ -52,926 +33,43 @@ export function useGlobeShips( onGlobeShipsReady?: (ready: boolean) => void; }, ) { - const { - projection, settings, shipData, shipHighlightSet, shipHoverOverlaySet, - shipLayerData, mapSyncEpoch, onSelectMmsi, onToggleHighlightMmsi, targets, - overlays, legacyHits, selectedMmsi, isBaseHighlightedMmsi, hasAuxiliarySelectModifier, - onGlobeShipsReady, - } = opts; + // Mercator 모드 선명 라벨 + useGlobeShipLabels(mapRef, projectionBusyRef, { + projection: opts.projection, + settings: opts.settings, + shipData: opts.shipData, + shipHighlightSet: opts.shipHighlightSet, + overlays: opts.overlays, + legacyHits: opts.legacyHits, + selectedMmsi: opts.selectedMmsi, + mapSyncEpoch: opts.mapSyncEpoch, + }); - const globeShipsEpochRef = useRef(-1); - const globeHoverShipSignatureRef = useRef(''); + // Globe 모드 선박 아이콘 레이어 + useGlobeShipLayers(mapRef, projectionBusyRef, reorderGlobeFeatureLayers, { + projection: opts.projection, + settings: opts.settings, + shipData: opts.shipData, + overlays: opts.overlays, + legacyHits: opts.legacyHits, + selectedMmsi: opts.selectedMmsi, + isBaseHighlightedMmsi: opts.isBaseHighlightedMmsi, + mapSyncEpoch: opts.mapSyncEpoch, + onGlobeShipsReady: opts.onGlobeShipsReady, + }); - // 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 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 (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; - 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); - } - } - // 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(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); - } - } - // lite symbol: lower LOD for non-priority vessels in low zoom - - 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); - } - } - // 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 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]); + // Globe 호버 오버레이 + 클릭 선택 + useGlobeShipHover(mapRef, projectionBusyRef, reorderGlobeFeatureLayers, { + projection: opts.projection, + settings: opts.settings, + shipLayerData: opts.shipLayerData, + shipHoverOverlaySet: opts.shipHoverOverlaySet, + legacyHits: opts.legacyHits, + selectedMmsi: opts.selectedMmsi, + mapSyncEpoch: opts.mapSyncEpoch, + onSelectMmsi: opts.onSelectMmsi, + onToggleHighlightMmsi: opts.onToggleHighlightMmsi, + targets: opts.targets, + hasAuxiliarySelectModifier: opts.hasAuxiliarySelectModifier, + }); }