2026-02-16 23:35:03 +09:00
|
|
|
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';
|
2026-02-17 16:38:51 +09:00
|
|
|
import { GLOBE_SHIP_CIRCLE_RADIUS_PROP_EXPR } from '../lib/mlExpressions';
|
2026-02-16 23:35:03 +09:00
|
|
|
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<maplibregl.Map | null>,
|
2026-02-17 16:38:51 +09:00
|
|
|
_projectionBusyRef: MutableRefObject<boolean>,
|
2026-02-16 23:35:03 +09:00
|
|
|
reorderGlobeFeatureLayers: () => void,
|
|
|
|
|
opts: {
|
|
|
|
|
projection: MapProjectionId;
|
|
|
|
|
settings: Map3DSettings;
|
|
|
|
|
shipLayerData: AisTarget[];
|
|
|
|
|
shipHoverOverlaySet: Set<number>;
|
|
|
|
|
legacyHits: Map<number, LegacyVesselInfo> | 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 (projection !== 'globe' || !settings.showShips || shipHoverOverlaySet.size === 0) {
|
|
|
|
|
hideHover();
|
|
|
|
|
return;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
if (epochRef.current !== mapSyncEpoch) {
|
|
|
|
|
epochRef.current = mapSyncEpoch;
|
|
|
|
|
}
|
|
|
|
|
|
2026-02-17 16:38:51 +09:00
|
|
|
try {
|
|
|
|
|
ensureFallbackShipImage(map, imgId);
|
|
|
|
|
} catch { /* ignore */ }
|
2026-02-16 23:35:03 +09:00
|
|
|
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<GeoJSON.Point> = {
|
|
|
|
|
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: {
|
2026-02-17 16:38:51 +09:00
|
|
|
'circle-radius': GLOBE_SHIP_CIRCLE_RADIUS_PROP_EXPR,
|
2026-02-16 23:35:03 +09:00
|
|
|
'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 {
|
2026-02-17 16:38:51 +09:00
|
|
|
guardedSetVisibility(map, haloId, 'visible');
|
2026-02-16 23:35:03 +09:00
|
|
|
}
|
|
|
|
|
|
|
|
|
|
if (!map.getLayer(outlineId)) {
|
|
|
|
|
try {
|
|
|
|
|
map.addLayer(
|
|
|
|
|
{
|
|
|
|
|
id: outlineId,
|
|
|
|
|
type: 'circle',
|
|
|
|
|
source: srcId,
|
|
|
|
|
paint: {
|
2026-02-17 16:38:51 +09:00
|
|
|
'circle-radius': GLOBE_SHIP_CIRCLE_RADIUS_PROP_EXPR,
|
2026-02-16 23:35:03 +09:00
|
|
|
'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 {
|
2026-02-17 16:38:51 +09:00
|
|
|
guardedSetVisibility(map, outlineId, 'visible');
|
2026-02-16 23:35:03 +09:00
|
|
|
}
|
|
|
|
|
|
|
|
|
|
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 {
|
2026-02-17 16:38:51 +09:00
|
|
|
guardedSetVisibility(map, symbolId, 'visible');
|
2026-02-16 23:35:03 +09:00
|
|
|
}
|
|
|
|
|
|
|
|
|
|
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';
|
2026-02-17 16:38:51 +09:00
|
|
|
const clickedRadiusDeg2 = Math.pow(0.12, 2);
|
2026-02-16 23:35:03 +09:00
|
|
|
|
|
|
|
|
const onClick = (e: maplibregl.MapMouseEvent) => {
|
|
|
|
|
try {
|
2026-02-17 16:38:51 +09:00
|
|
|
const layerIds = [symbolId, symbolLiteId, haloId, outlineId, 'ships-globe-alarm-pulse', 'ships-globe-alarm-badge'].filter((id) => map.getLayer(id));
|
2026-02-16 23:35:03 +09:00
|
|
|
let feats: unknown[] = [];
|
|
|
|
|
if (layerIds.length > 0) {
|
|
|
|
|
try {
|
2026-02-17 16:38:51 +09:00
|
|
|
const tolerance = 10;
|
|
|
|
|
const bbox: [maplibregl.PointLike, maplibregl.PointLike] = [
|
|
|
|
|
[e.point.x - tolerance, e.point.y - tolerance],
|
|
|
|
|
[e.point.x + tolerance, e.point.y + tolerance],
|
|
|
|
|
];
|
|
|
|
|
feats = map.queryRenderedFeatures(bbox, { layers: layerIds }) as unknown[];
|
2026-02-16 23:35:03 +09:00
|
|
|
} catch {
|
|
|
|
|
feats = [];
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
const f = feats?.[0];
|
|
|
|
|
const props = ((f as { properties?: Record<string, unknown> } | 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]);
|
|
|
|
|
}
|