28개 useEffect + 30+ useCallback을 10개 커스텀 hook으로 추출: - useMapInit: MapLibre 인스턴스 생성 + Deck 오버레이 - useProjectionToggle: Mercator↔Globe 전환 - useBaseMapToggle: 베이스맵 전환 + 수심/해도 - useZonesLayer: 수역 GeoJSON 레이어 - usePredictionVectors: 예측 벡터 레이어 - useGlobeShips: Globe 선박 아이콘/라벨/호버/클릭 - useGlobeOverlays: Globe pair/fc/fleet/range 레이어 - useGlobeInteraction: Globe 마우스 이벤트 + 툴팁 - useDeckLayers: Mercator + Globe Deck 레이어 - useFlyTo: 카메라 이동 Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
211 lines
7.1 KiB
TypeScript
211 lines
7.1 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 { LEGACY_CODE_COLORS_RGB, OTHER_AIS_SPEED_RGB, rgba as rgbaCss } from '../../../shared/lib/map/palette';
|
|
import { kickRepaint, onMapStyleReady } from '../lib/mapCore';
|
|
import { destinationPointLngLat } from '../lib/geometry';
|
|
import { isFiniteNumber } from '../lib/setUtils';
|
|
import { toValidBearingDeg, lightenColor } from '../lib/shipUtils';
|
|
|
|
export function usePredictionVectors(
|
|
mapRef: MutableRefObject<maplibregl.Map | null>,
|
|
projectionBusyRef: MutableRefObject<boolean>,
|
|
reorderGlobeFeatureLayers: () => void,
|
|
opts: {
|
|
overlays: MapToggleState;
|
|
settings: Map3DSettings;
|
|
shipData: AisTarget[];
|
|
legacyHits: Map<number, LegacyVesselInfo> | null | undefined;
|
|
selectedMmsi: number | null;
|
|
externalHighlightedSetRef: Set<number>;
|
|
projection: MapProjectionId;
|
|
baseMap: string;
|
|
mapSyncEpoch: number;
|
|
},
|
|
) {
|
|
const { overlays, settings, shipData, legacyHits, selectedMmsi, externalHighlightedSetRef, projection, baseMap, mapSyncEpoch } = opts;
|
|
|
|
useEffect(() => {
|
|
const map = mapRef.current;
|
|
if (!map) return;
|
|
|
|
const srcId = 'predict-vectors-src';
|
|
const outlineId = 'predict-vectors-outline';
|
|
const lineId = 'predict-vectors';
|
|
const hlOutlineId = 'predict-vectors-hl-outline';
|
|
const hlId = 'predict-vectors-hl';
|
|
|
|
const ensure = () => {
|
|
if (projectionBusyRef.current) return;
|
|
if (!map.isStyleLoaded()) return;
|
|
|
|
const visibility = overlays.predictVectors ? 'visible' : 'none';
|
|
|
|
const horizonMinutes = 15;
|
|
const horizonSeconds = horizonMinutes * 60;
|
|
const metersPerSecondPerKnot = 0.514444;
|
|
|
|
const features: GeoJSON.Feature<GeoJSON.LineString>[] = [];
|
|
if (overlays.predictVectors && settings.showShips && shipData.length > 0) {
|
|
for (const t of shipData) {
|
|
const legacy = legacyHits?.get(t.mmsi) ?? null;
|
|
const isTarget = !!legacy;
|
|
const isSelected = selectedMmsi != null && t.mmsi === selectedMmsi;
|
|
const isPinnedHighlight = externalHighlightedSetRef.has(t.mmsi);
|
|
if (!isTarget && !isSelected && !isPinnedHighlight) continue;
|
|
|
|
const sog = isFiniteNumber(t.sog) ? t.sog : null;
|
|
const bearing = toValidBearingDeg(t.cog) ?? toValidBearingDeg(t.heading);
|
|
if (sog == null || bearing == null) continue;
|
|
if (sog < 0.2) continue;
|
|
|
|
const distM = sog * metersPerSecondPerKnot * horizonSeconds;
|
|
if (!Number.isFinite(distM) || distM <= 0) continue;
|
|
|
|
const to = destinationPointLngLat([t.lon, t.lat], bearing, distM);
|
|
|
|
const baseRgb = isTarget
|
|
? LEGACY_CODE_COLORS_RGB[legacy?.shipCode ?? ''] ?? OTHER_AIS_SPEED_RGB.moving
|
|
: OTHER_AIS_SPEED_RGB.moving;
|
|
const rgb = lightenColor(baseRgb, isTarget ? 0.55 : 0.62);
|
|
const alpha = isTarget ? 0.72 : 0.52;
|
|
const alphaHl = isTarget ? 0.92 : 0.84;
|
|
const hl = isSelected || isPinnedHighlight ? 1 : 0;
|
|
|
|
features.push({
|
|
type: 'Feature',
|
|
id: `pred-${t.mmsi}`,
|
|
geometry: { type: 'LineString', coordinates: [[t.lon, t.lat], to] },
|
|
properties: {
|
|
mmsi: t.mmsi,
|
|
minutes: horizonMinutes,
|
|
sog,
|
|
cog: bearing,
|
|
target: isTarget ? 1 : 0,
|
|
hl,
|
|
color: rgbaCss(rgb, alpha),
|
|
colorHl: rgbaCss(rgb, alphaHl),
|
|
},
|
|
});
|
|
}
|
|
}
|
|
|
|
const fc: GeoJSON.FeatureCollection<GeoJSON.LineString> = { 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('Prediction vector source setup failed:', e);
|
|
return;
|
|
}
|
|
|
|
const ensureLayer = (id: string, paint: LayerSpecification['paint'], filter: unknown[]) => {
|
|
if (!map.getLayer(id)) {
|
|
try {
|
|
map.addLayer(
|
|
{
|
|
id,
|
|
type: 'line',
|
|
source: srcId,
|
|
filter: filter as never,
|
|
layout: {
|
|
visibility,
|
|
'line-cap': 'round',
|
|
'line-join': 'round',
|
|
},
|
|
paint,
|
|
} as unknown as LayerSpecification,
|
|
undefined,
|
|
);
|
|
} catch (e) {
|
|
console.warn('Prediction vector layer add failed:', e);
|
|
}
|
|
} else {
|
|
try {
|
|
map.setLayoutProperty(id, 'visibility', visibility);
|
|
map.setFilter(id, filter as never);
|
|
if (paint && typeof paint === 'object') {
|
|
for (const [key, value] of Object.entries(paint)) {
|
|
map.setPaintProperty(id, key as never, value as never);
|
|
}
|
|
}
|
|
} catch {
|
|
// ignore
|
|
}
|
|
}
|
|
};
|
|
|
|
const baseFilter = ['==', ['to-number', ['get', 'hl'], 0], 0] as unknown as unknown[];
|
|
const hlFilter = ['==', ['to-number', ['get', 'hl'], 0], 1] as unknown as unknown[];
|
|
|
|
ensureLayer(
|
|
outlineId,
|
|
{
|
|
'line-color': 'rgba(2,6,23,0.86)',
|
|
'line-width': 4.8,
|
|
'line-opacity': 1,
|
|
'line-blur': 0.2,
|
|
'line-dasharray': [1.2, 1.8] as never,
|
|
} as never,
|
|
baseFilter,
|
|
);
|
|
ensureLayer(
|
|
lineId,
|
|
{
|
|
'line-color': ['coalesce', ['get', 'color'], 'rgba(226,232,240,0.62)'] as never,
|
|
'line-width': 2.4,
|
|
'line-opacity': 1,
|
|
'line-dasharray': [1.2, 1.8] as never,
|
|
} as never,
|
|
baseFilter,
|
|
);
|
|
ensureLayer(
|
|
hlOutlineId,
|
|
{
|
|
'line-color': 'rgba(2,6,23,0.92)',
|
|
'line-width': 6.4,
|
|
'line-opacity': 1,
|
|
'line-blur': 0.25,
|
|
'line-dasharray': [1.2, 1.8] as never,
|
|
} as never,
|
|
hlFilter,
|
|
);
|
|
ensureLayer(
|
|
hlId,
|
|
{
|
|
'line-color': ['coalesce', ['get', 'colorHl'], ['get', 'color'], 'rgba(241,245,249,0.92)'] as never,
|
|
'line-width': 3.6,
|
|
'line-opacity': 1,
|
|
'line-dasharray': [1.2, 1.8] as never,
|
|
} as never,
|
|
hlFilter,
|
|
);
|
|
|
|
reorderGlobeFeatureLayers();
|
|
kickRepaint(map);
|
|
};
|
|
|
|
const stop = onMapStyleReady(map, ensure);
|
|
return () => {
|
|
stop();
|
|
};
|
|
}, [
|
|
overlays.predictVectors,
|
|
settings.showShips,
|
|
shipData,
|
|
legacyHits,
|
|
selectedMmsi,
|
|
externalHighlightedSetRef,
|
|
projection,
|
|
baseMap,
|
|
mapSyncEpoch,
|
|
reorderGlobeFeatureLayers,
|
|
]);
|
|
}
|