gc-wing/apps/web/src/widgets/map3d/hooks/usePredictionVectors.ts
htlee c5d89c5641 refactor(map): Map3D.tsx hooks 추출 완료 (4558줄 → 510줄)
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>
2026-02-16 00:41:11 +09:00

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,
]);
}