refactor(map3d): useGlobeShips 977줄 → 서브훅 3+1개 분리
- useGlobeShipLabels: Mercator 선명 라벨 - useGlobeShipLayers: Globe 선박 아이콘 레이어 + GeoJSON - useGlobeShipHover: Globe 호버 오버레이 + 클릭 선택 - useGlobeShips: 오케스트레이터 (기존 호출부 호환) Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
부모
c2ca830ef0
커밋
e2dc927ad2
369
apps/web/src/widgets/map3d/hooks/useGlobeShipHover.ts
Normal file
369
apps/web/src/widgets/map3d/hooks/useGlobeShipHover.ts
Normal file
@ -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<maplibregl.Map | null>,
|
||||||
|
projectionBusyRef: MutableRefObject<boolean>,
|
||||||
|
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 (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<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 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<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]);
|
||||||
|
}
|
||||||
164
apps/web/src/widgets/map3d/hooks/useGlobeShipLabels.ts
Normal file
164
apps/web/src/widgets/map3d/hooks/useGlobeShipLabels.ts
Normal file
@ -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<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,
|
||||||
|
]);
|
||||||
|
}
|
||||||
501
apps/web/src/widgets/map3d/hooks/useGlobeShipLayers.ts
Normal file
501
apps/web/src/widgets/map3d/hooks/useGlobeShipLayers.ts
Normal file
@ -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<maplibregl.Map | null>,
|
||||||
|
projectionBusyRef: MutableRefObject<boolean>,
|
||||||
|
reorderGlobeFeatureLayers: () => void,
|
||||||
|
opts: {
|
||||||
|
projection: MapProjectionId;
|
||||||
|
settings: Map3DSettings;
|
||||||
|
shipData: AisTarget[];
|
||||||
|
overlays: MapToggleState;
|
||||||
|
legacyHits: Map<number, LegacyVesselInfo> | 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<GeoJSON.Point> => {
|
||||||
|
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,
|
||||||
|
]);
|
||||||
|
}
|
||||||
@ -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 maplibregl from 'maplibre-gl';
|
||||||
import type { GeoJSONSource, GeoJSONSourceSpecification, LayerSpecification } from 'maplibre-gl';
|
|
||||||
import type { AisTarget } from '../../../entities/aisTarget/model/types';
|
import type { AisTarget } from '../../../entities/aisTarget/model/types';
|
||||||
import type { LegacyVesselInfo } from '../../../entities/legacyVessel/model/types';
|
import type { LegacyVesselInfo } from '../../../entities/legacyVessel/model/types';
|
||||||
import type { MapToggleState } from '../../../features/mapToggles/MapToggles';
|
import type { MapToggleState } from '../../../features/mapToggles/MapToggles';
|
||||||
import type { Map3DSettings, MapProjectionId } from '../types';
|
import type { Map3DSettings, MapProjectionId } from '../types';
|
||||||
import {
|
import { useGlobeShipLabels } from './useGlobeShipLabels';
|
||||||
ANCHORED_SHIP_ICON_ID,
|
import { useGlobeShipLayers } from './useGlobeShipLayers';
|
||||||
GLOBE_ICON_HEADING_OFFSET_DEG,
|
import { useGlobeShipHover } from './useGlobeShipHover';
|
||||||
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';
|
|
||||||
|
|
||||||
export function useGlobeShips(
|
export function useGlobeShips(
|
||||||
mapRef: MutableRefObject<maplibregl.Map | null>,
|
mapRef: MutableRefObject<maplibregl.Map | null>,
|
||||||
@ -52,926 +33,43 @@ export function useGlobeShips(
|
|||||||
onGlobeShipsReady?: (ready: boolean) => void;
|
onGlobeShipsReady?: (ready: boolean) => void;
|
||||||
},
|
},
|
||||||
) {
|
) {
|
||||||
const {
|
// Mercator 모드 선명 라벨
|
||||||
projection, settings, shipData, shipHighlightSet, shipHoverOverlaySet,
|
useGlobeShipLabels(mapRef, projectionBusyRef, {
|
||||||
shipLayerData, mapSyncEpoch, onSelectMmsi, onToggleHighlightMmsi, targets,
|
projection: opts.projection,
|
||||||
overlays, legacyHits, selectedMmsi, isBaseHighlightedMmsi, hasAuxiliarySelectModifier,
|
settings: opts.settings,
|
||||||
onGlobeShipsReady,
|
shipData: opts.shipData,
|
||||||
} = opts;
|
shipHighlightSet: opts.shipHighlightSet,
|
||||||
|
overlays: opts.overlays,
|
||||||
const globeShipsEpochRef = useRef(-1);
|
legacyHits: opts.legacyHits,
|
||||||
const globeHoverShipSignatureRef = useRef('');
|
selectedMmsi: opts.selectedMmsi,
|
||||||
|
mapSyncEpoch: opts.mapSyncEpoch,
|
||||||
// Globe GeoJSON을 projection과 무관하게 항상 사전 계산
|
|
||||||
// Globe 전환 시 즉시 사용 가능하도록 useMemo로 캐싱
|
|
||||||
const globeShipGeoJson = useMemo((): GeoJSON.FeatureCollection<GeoJSON.Point> => {
|
|
||||||
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
|
// Globe 모드 선박 아이콘 레이어
|
||||||
useEffect(() => {
|
useGlobeShipLayers(mapRef, projectionBusyRef, reorderGlobeFeatureLayers, {
|
||||||
const map = mapRef.current;
|
projection: opts.projection,
|
||||||
if (!map) return;
|
settings: opts.settings,
|
||||||
|
shipData: opts.shipData,
|
||||||
const srcId = 'ship-labels-src';
|
overlays: opts.overlays,
|
||||||
const layerId = 'ship-labels';
|
legacyHits: opts.legacyHits,
|
||||||
|
selectedMmsi: opts.selectedMmsi,
|
||||||
const remove = () => {
|
isBaseHighlightedMmsi: opts.isBaseHighlightedMmsi,
|
||||||
try {
|
mapSyncEpoch: opts.mapSyncEpoch,
|
||||||
if (map.getLayer(layerId)) map.removeLayer(layerId);
|
onGlobeShipsReady: opts.onGlobeShipsReady,
|
||||||
} 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 };
|
// Globe 호버 오버레이 + 클릭 선택
|
||||||
|
useGlobeShipHover(mapRef, projectionBusyRef, reorderGlobeFeatureLayers, {
|
||||||
try {
|
projection: opts.projection,
|
||||||
const existing = map.getSource(srcId) as GeoJSONSource | undefined;
|
settings: opts.settings,
|
||||||
if (existing) existing.setData(fc);
|
shipLayerData: opts.shipLayerData,
|
||||||
else map.addSource(srcId, { type: 'geojson', data: fc } as GeoJSONSourceSpecification);
|
shipHoverOverlaySet: opts.shipHoverOverlaySet,
|
||||||
} catch (e) {
|
legacyHits: opts.legacyHits,
|
||||||
console.warn('Ship label source setup failed:', e);
|
selectedMmsi: opts.selectedMmsi,
|
||||||
return;
|
mapSyncEpoch: opts.mapSyncEpoch,
|
||||||
}
|
onSelectMmsi: opts.onSelectMmsi,
|
||||||
|
onToggleHighlightMmsi: opts.onToggleHighlightMmsi,
|
||||||
const filter = ['!=', ['to-string', ['coalesce', ['get', 'labelName'], '']], ''] as unknown as unknown[];
|
targets: opts.targets,
|
||||||
|
hasAuxiliarySelectModifier: opts.hasAuxiliarySelectModifier,
|
||||||
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<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 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<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]);
|
|
||||||
}
|
}
|
||||||
|
|||||||
불러오는 중...
Reference in New Issue
Block a user