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 { 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'; 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; }, ) { const { projection, settings, shipData, shipHighlightSet, shipHoverOverlaySet, shipLayerData, mapSyncEpoch, onSelectMmsi, onToggleHighlightMmsi, targets, overlays, legacyHits, selectedMmsi, isBaseHighlightedMmsi, hasAuxiliarySelectModifier, } = opts; const globeShipsEpochRef = useRef(-1); const globeShipIconLoadingRef = useRef(false); const globeHoverShipSignatureRef = useRef(''); // 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'; const remove = () => { for (const id of [labelId, symbolId, outlineId, haloId]) { try { if (map.getLayer(id)) map.removeLayer(id); } catch { // ignore } } try { if (map.getSource(srcId)) map.removeSource(srcId); } catch { // ignore } globeHoverShipSignatureRef.current = ''; reorderGlobeFeatureLayers(); kickRepaint(map); }; const ensureImage = () => { ensureFallbackShipImage(map, imgId); ensureFallbackShipImage(map, anchoredImgId, buildFallbackGlobeAnchoredShipIcon); if (globeShipIconLoadingRef.current) return; if (map.hasImage(imgId) && map.hasImage(anchoredImgId)) return; const addFallbackImage = () => { ensureFallbackShipImage(map, imgId); ensureFallbackShipImage(map, anchoredImgId, buildFallbackGlobeAnchoredShipIcon); kickRepaint(map); }; let fallbackTimer: ReturnType | null = null; try { globeShipIconLoadingRef.current = true; fallbackTimer = window.setTimeout(() => { addFallbackImage(); }, 80); void map .loadImage('/assets/ship.svg') .then((response) => { globeShipIconLoadingRef.current = false; if (fallbackTimer != null) { clearTimeout(fallbackTimer); fallbackTimer = null; } const loadedImage = (response as { data?: HTMLImageElement | ImageBitmap } | undefined)?.data; if (!loadedImage) { addFallbackImage(); return; } try { if (map.hasImage(imgId)) { try { map.removeImage(imgId); } catch { // ignore } } if (map.hasImage(anchoredImgId)) { try { map.removeImage(anchoredImgId); } catch { // ignore } } map.addImage(imgId, loadedImage, { pixelRatio: 2, sdf: true }); map.addImage(anchoredImgId, loadedImage, { pixelRatio: 2, sdf: true }); kickRepaint(map); } catch (e) { console.warn('Ship icon image add failed:', e); } }) .catch(() => { globeShipIconLoadingRef.current = false; if (fallbackTimer != null) { clearTimeout(fallbackTimer); fallbackTimer = null; } addFallbackImage(); }); } catch (e) { globeShipIconLoadingRef.current = false; if (fallbackTimer != null) { clearTimeout(fallbackTimer); fallbackTimer = null; } try { addFallbackImage(); } catch (fallbackError) { console.warn('Ship icon image setup failed:', e, fallbackError); } } }; const ensure = () => { if (projectionBusyRef.current) return; if (!map.isStyleLoaded()) return; if (projection !== 'globe' || !settings.showShips) { remove(); return; } if (globeShipsEpochRef.current !== mapSyncEpoch) { globeShipsEpochRef.current = mapSyncEpoch; } try { ensureImage(); } catch (e) { console.warn('Ship icon image setup failed:', e); } const globeShipData = shipData; const geojson: GeoJSON.FeatureCollection = { type: 'FeatureCollection', features: globeShipData.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', ...(isFiniteNumber(t.mmsi) ? { id: Math.trunc(t.mmsi) } : {}), geometry: { type: 'Point', 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 || '', }, }; }), }; 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 visibility = settings.showShips ? 'visible' : 'none'; 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); } } else { try { map.setLayoutProperty(haloId, 'visibility', visibility); map.setLayoutProperty(haloId, '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); map.setPaintProperty(haloId, 'circle-color', [ 'case', ['==', ['get', 'selected'], 1], 'rgba(14,234,255,1)', ['==', ['get', 'highlighted'], 1], 'rgba(245,158,11,1)', ['coalesce', ['get', 'shipColor'], '#64748b'], ] as never); map.setPaintProperty(haloId, 'circle-opacity', [ 'case', ['==', ['get', 'selected'], 1], 0.38, ['==', ['get', 'highlighted'], 1], 0.34, 0.16, ] as never); map.setPaintProperty(haloId, 'circle-radius', GLOBE_SHIP_CIRCLE_RADIUS_EXPR); } catch { // ignore } } 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); } } else { try { map.setLayoutProperty(outlineId, 'visibility', visibility); map.setLayoutProperty(outlineId, '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); map.setPaintProperty(outlineId, '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); map.setPaintProperty(outlineId, 'circle-stroke-width', [ 'case', ['==', ['get', 'selected'], 1], 3.4, ['==', ['get', 'highlighted'], 1], 2.7, ['==', ['get', 'permitted'], 1], 1.8, 0.7, ] as never); } catch { // ignore } } 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); } } else { try { map.setLayoutProperty(symbolId, 'visibility', visibility); map.setLayoutProperty(symbolId, '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); map.setPaintProperty(symbolId, 'icon-opacity', [ 'case', ['==', ['get', 'permitted'], 1], 1, ['==', ['get', 'selected'], 1], 0.86, ['==', ['get', 'highlighted'], 1], 0.82, 0.66, ] as never); } catch { // ignore } } const labelVisibility = overlays.shipLabels ? 'visible' : 'none'; 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); } } else { try { map.setLayoutProperty(labelId, 'visibility', labelVisibility); map.setFilter(labelId, labelFilter as never); map.setLayoutProperty(labelId, 'text-field', ['get', 'labelName'] as never); } catch { // ignore } } reorderGlobeFeatureLayers(); kickRepaint(map); }; const stop = onMapStyleReady(map, ensure); return () => { stop(); }; }, [ projection, settings.showShips, overlays.shipLabels, shipData, legacyHits, selectedMmsi, isBaseHighlightedMmsi, mapSyncEpoch, reorderGlobeFeatureLayers, ]); // 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 remove = () => { for (const id of [symbolId, outlineId, haloId]) { try { if (map.getLayer(id)) map.removeLayer(id); } catch { // ignore } } try { if (map.getSource(srcId)) map.removeSource(srcId); } catch { // ignore } globeHoverShipSignatureRef.current = ''; reorderGlobeFeatureLayers(); kickRepaint(map); }; const ensure = () => { if (projectionBusyRef.current) return; if (!map.isStyleLoaded()) return; if (projection !== 'globe' || !settings.showShips || shipHoverOverlaySet.size === 0) { remove(); 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) { remove(); 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]); }