- useGlobeShipLabels: Mercator 선명 라벨 - useGlobeShipLayers: Globe 선박 아이콘 레이어 + GeoJSON - useGlobeShipHover: Globe 호버 오버레이 + 클릭 선택 - useGlobeShips: 오케스트레이터 (기존 호출부 호환) Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
165 lines
5.2 KiB
TypeScript
165 lines
5.2 KiB
TypeScript
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<maplibregl.Map | null>,
|
|
projectionBusyRef: MutableRefObject<boolean>,
|
|
opts: {
|
|
projection: MapProjectionId;
|
|
settings: Map3DSettings;
|
|
shipData: AisTarget[];
|
|
shipHighlightSet: Set<number>;
|
|
overlays: MapToggleState;
|
|
legacyHits: Map<number, LegacyVesselInfo> | 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<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,
|
|
]);
|
|
}
|