gc-wing/apps/web/src/widgets/map3d/hooks/useGlobeShips.ts

1039 lines
35 KiB
TypeScript
Raw Normal View 히스토리

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<maplibregl.Map | null>,
projectionBusyRef: MutableRefObject<boolean>,
reorderGlobeFeatureLayers: () => void,
opts: {
projection: MapProjectionId;
settings: Map3DSettings;
shipData: AisTarget[];
shipHighlightSet: Set<number>;
shipHoverOverlaySet: Set<number>;
shipOverlayLayerData: AisTarget[];
shipLayerData: AisTarget[];
shipByMmsi: Map<number, AisTarget>;
mapSyncEpoch: number;
onSelectMmsi: (mmsi: number | null) => void;
onToggleHighlightMmsi?: (mmsi: number) => void;
targets: AisTarget[];
overlays: MapToggleState;
legacyHits: Map<number, LegacyVesselInfo> | 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<GeoJSON.Point>[] = [];
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<GeoJSON.Point> = { 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<typeof window.setTimeout> | 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<GeoJSON.Point> = {
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<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: {
'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<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]);
}