gc-wing/apps/web/src/widgets/map3d/hooks/useGlobeFcFleetOverlay.ts
htlee d0f67ae803 feat(encMap): gcnautical 타일 서버 기반 ENC 전자해도 + UI 개선
## ENC 베이스맵 (features/encMap/)
- gcnautical 타일 서버 연동 (nautical.json 49개 레이어, 73개 S-52 스프라이트)
- 설정 패널: 12개 레이어 토글, 영역 색상 3종, 수심 색상 5단계
- 배경색 밝기 기반 선박 라벨 색상 자동 전환 (labelColor.ts)
- useMapStyleSettings에 ENC 가드 추가 (스타일 간섭 방지)
- useBaseMapToggle 초기 로드 스킵 (useMapInit과 중복 setStyle 방지)

## 선박 표시 개선
- Globe 원형 halo/outline 제거 — 아이콘 본체만 표시
- Globe 아이콘 스케일 1.3배, 줌아웃 최소 크기 보장 (minzoom 2)
- SDF icon-halo로 테두리 적용 (성능 영향 없음)
- 기타 AIS 투명도 상향 (0.28→0.6 ~ 1.0)
- 선박명 영문 우선 표시 (shipNameRoman > shipNameCn)

## 오버레이 제어 수정
- 연결선/범위/선단 토글 off 시 인터랙티브 오버레이도 비활성
- Globe pair/fc/fleet 레이어: || active 제거 → 토글 우선
- 강조 링/알람 링: shipData→shipLayerData (클러스터링 연동)

## 기본값 변경
- 경고 필터 5개: 초기 false
- 연결선/범위/선단: 초기 false
- 사진 파란 원 아이콘: Globe+Mercator 모두 제거

## 폰트 정리
- Open Sans 폴백 전면 제거 → Noto Sans 단독
- ENC 스타일 fetch 시 text-font 패치

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-25 14:19:28 +09:00

356 lines
13 KiB
TypeScript

import { useCallback, useEffect, useRef, type MutableRefObject } from 'react';
import type maplibregl from 'maplibre-gl';
import type { GeoJSONSource, GeoJSONSourceSpecification, LayerSpecification } from 'maplibre-gl';
import type { FcLink, FleetCircle } from '../../../features/legacyDashboard/model/types';
import type { MapToggleState } from '../../../features/mapToggles/MapToggles';
import type { DashSeg, MapProjectionId } from '../types';
import {
FC_LINE_NORMAL_ML, FC_LINE_SUSPICIOUS_ML,
FC_LINE_NORMAL_ML_HL, FC_LINE_SUSPICIOUS_ML_HL,
FLEET_LINE_ML, FLEET_LINE_ML_HL,
} from '../constants';
import { makeUniqueSorted } from '../lib/setUtils';
import {
makeFcSegmentFeatureId,
makeFleetCircleFeatureId,
} from '../lib/featureIds';
import {
makeMmsiAnyEndpointExpr,
makeFleetOwnerMatchExpr,
makeFleetMemberMatchExpr,
} from '../lib/mlExpressions';
import { kickRepaint, onMapStyleReady } from '../lib/mapCore';
import { circleRingLngLat } from '../lib/geometry';
import { dashifyLine } from '../lib/dashifyLine';
// ── Overlay line width constants ──
const FC_LINE_W_NORMAL = 2.2;
const FC_LINE_W_HL = 3.2;
const FLEET_LINE_W_NORMAL = 2.0;
const FLEET_LINE_W_HL = 3.0;
// ── Breathing animation constants ──
const BREATHE_AMP = 2.0;
const BREATHE_PERIOD_MS = 1200;
/** Globe FC lines + fleet circles 오버레이 (stroke only — fill 제거) */
export function useGlobeFcFleetOverlay(
mapRef: MutableRefObject<maplibregl.Map | null>,
_projectionBusyRef: MutableRefObject<boolean>,
reorderGlobeFeatureLayers: () => void,
opts: {
overlays: MapToggleState;
fcLinks: FcLink[] | undefined;
fleetCircles: FleetCircle[] | undefined;
projection: MapProjectionId;
mapSyncEpoch: number;
hoveredFleetMmsiList: number[];
hoveredFleetOwnerKeyList: string[];
hoveredPairMmsiList: number[];
},
) {
const {
overlays, fcLinks, fleetCircles, projection, mapSyncEpoch,
hoveredFleetMmsiList, hoveredFleetOwnerKeyList, hoveredPairMmsiList,
} = opts;
const breatheRafRef = useRef<number>(0);
// paint state ref — 데이터 effect에서 레이어 생성 직후 최신 paint state를 즉시 적용하기 위해 사용
const paintStateRef = useRef<() => void>(() => {});
// ── FC lines 데이터 effect ──
// projectionBusy/isStyleLoaded 선행 가드 제거 — try/catch로 처리
// 실패 시 다음 AIS poll(mapSyncEpoch 변경)에서 자연스럽게 재시도
useEffect(() => {
const map = mapRef.current;
if (!map) return;
const srcId = 'fc-lines-ml-src';
const layerId = 'fc-lines-ml';
const ensure = () => {
if (projection !== 'globe') {
try {
if (map.getLayer(layerId)) map.setPaintProperty(layerId, 'line-opacity', 0);
} catch { /* ignore */ }
return;
}
const segs: DashSeg[] = [];
for (const l of fcLinks || []) {
segs.push(...dashifyLine(l.from, l.to, l.suspicious, l.distanceNm, l.fcMmsi, l.otherMmsi));
}
if (segs.length === 0) {
try {
if (map.getLayer(layerId)) map.setPaintProperty(layerId, 'line-opacity', 0);
} catch { /* ignore */ }
return;
}
const fc: GeoJSON.FeatureCollection<GeoJSON.LineString> = {
type: 'FeatureCollection',
features: segs.map((s, idx) => ({
type: 'Feature',
id: makeFcSegmentFeatureId(s.fromMmsi ?? -1, s.toMmsi ?? -1, idx),
geometry: { type: 'LineString', coordinates: [s.from, s.to] },
properties: {
type: 'fc',
suspicious: s.suspicious,
distanceNm: s.distanceNm,
fcMmsi: s.fromMmsi ?? -1,
otherMmsi: s.toMmsi ?? -1,
},
})),
};
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 {
return; // 다음 poll에서 재시도
}
const needReorder = !map.getLayer(layerId);
if (needReorder) {
try {
map.addLayer(
{
id: layerId,
type: 'line',
source: srcId,
layout: { 'line-cap': 'round', 'line-join': 'round', visibility: 'visible' },
paint: {
'line-color': FC_LINE_NORMAL_ML,
'line-width': FC_LINE_W_NORMAL,
'line-opacity': 0,
},
} as unknown as LayerSpecification,
undefined,
);
} catch {
return; // 다음 poll에서 재시도
}
reorderGlobeFeatureLayers();
}
paintStateRef.current();
kickRepaint(map);
};
// 초기 style 로드 대기 — 이후에는 AIS poll 사이클이 재시도 보장
const stop = onMapStyleReady(map, ensure);
ensure();
return () => { stop(); };
}, [projection, fcLinks, mapSyncEpoch, reorderGlobeFeatureLayers]);
// ── Fleet circles 데이터 effect (stroke only — fill 제거) ──
useEffect(() => {
const map = mapRef.current;
if (!map) return;
const srcId = 'fleet-circles-ml-src';
const layerId = 'fleet-circles-ml';
const ensure = () => {
if (projection !== 'globe' || (fleetCircles?.length ?? 0) === 0) {
try {
if (map.getLayer(layerId)) map.setPaintProperty(layerId, 'line-opacity', 0);
} catch { /* ignore */ }
return;
}
const circles = fleetCircles || [];
const fcLine: GeoJSON.FeatureCollection<GeoJSON.LineString> = {
type: 'FeatureCollection',
features: circles.map((c) => {
const ring = circleRingLngLat(c.center, c.radiusNm * 1852);
return {
type: 'Feature',
id: makeFleetCircleFeatureId(c.ownerKey),
geometry: { type: 'LineString', coordinates: ring },
properties: {
type: 'fleet',
ownerKey: c.ownerKey,
ownerLabel: c.ownerLabel,
count: c.count,
vesselMmsis: c.vesselMmsis,
},
};
}),
};
try {
const existing = map.getSource(srcId) as GeoJSONSource | undefined;
if (existing) existing.setData(fcLine);
else map.addSource(srcId, { type: 'geojson', data: fcLine } as GeoJSONSourceSpecification);
} catch {
return; // 다음 poll에서 재시도
}
const needReorder = !map.getLayer(layerId);
if (needReorder) {
try {
map.addLayer(
{
id: layerId,
type: 'line',
source: srcId,
layout: { 'line-cap': 'round', 'line-join': 'round', visibility: 'visible' },
paint: {
'line-color': FLEET_LINE_ML,
'line-width': FLEET_LINE_W_NORMAL,
'line-opacity': 0,
},
} as unknown as LayerSpecification,
undefined,
);
} catch {
return; // 다음 poll에서 재시도
}
reorderGlobeFeatureLayers();
}
paintStateRef.current();
kickRepaint(map);
};
const stop = onMapStyleReady(map, ensure);
ensure();
return () => { stop(); };
}, [projection, fleetCircles, mapSyncEpoch, reorderGlobeFeatureLayers]);
// ── FC + Fleet paint state update (가시성 + 하이라이트 통합) ──
// eslint-disable-next-line react-hooks/preserve-manual-memoization
const updateFcFleetPaintStates = useCallback(() => {
if (projection !== 'globe') return;
const map = mapRef.current;
if (!map) return;
const fleetAwarePairMmsiList = makeUniqueSorted([...hoveredPairMmsiList, ...hoveredFleetMmsiList]);
const fcEndpointHighlightExpr = fleetAwarePairMmsiList.length > 0
? makeMmsiAnyEndpointExpr('fcMmsi', 'otherMmsi', fleetAwarePairMmsiList)
: false;
const fleetOwnerMatchExpr =
hoveredFleetOwnerKeyList.length > 0 ? makeFleetOwnerMatchExpr(hoveredFleetOwnerKeyList) : false;
const fleetMemberExpr =
hoveredFleetMmsiList.length > 0 ? makeFleetMemberMatchExpr(hoveredFleetMmsiList) : false;
const fleetHighlightExpr =
hoveredFleetOwnerKeyList.length > 0 || hoveredFleetMmsiList.length > 0
? (['any', fleetOwnerMatchExpr, fleetMemberExpr] as never)
: false;
// ── FC lines ──
const fcVisible = overlays.fcLines;
// ── Fleet circles ──
const fleetVisible = overlays.fleetCircles;
try {
if (map.getLayer('fc-lines-ml')) {
map.setPaintProperty('fc-lines-ml', 'line-opacity', fcVisible ? 0.9 : 0);
if (fcVisible) {
map.setPaintProperty(
'fc-lines-ml', 'line-color',
fcEndpointHighlightExpr !== false
? ['case', fcEndpointHighlightExpr, ['case', ['boolean', ['get', 'suspicious'], false], FC_LINE_SUSPICIOUS_ML_HL, FC_LINE_NORMAL_ML_HL], ['boolean', ['get', 'suspicious'], false], FC_LINE_SUSPICIOUS_ML, FC_LINE_NORMAL_ML] as never
: ['case', ['boolean', ['get', 'suspicious'], false], FC_LINE_SUSPICIOUS_ML, FC_LINE_NORMAL_ML] as never,
);
map.setPaintProperty(
'fc-lines-ml', 'line-width',
fcEndpointHighlightExpr !== false
? ['case', fcEndpointHighlightExpr, FC_LINE_W_HL, FC_LINE_W_NORMAL] as never
: FC_LINE_W_NORMAL,
);
}
}
} catch {
// ignore
}
try {
if (map.getLayer('fleet-circles-ml')) {
map.setPaintProperty('fleet-circles-ml', 'line-opacity', fleetVisible ? 0.85 : 0);
if (fleetVisible) {
map.setPaintProperty('fleet-circles-ml', 'line-color',
fleetHighlightExpr !== false
? ['case', fleetHighlightExpr, FLEET_LINE_ML_HL, FLEET_LINE_ML] as never
: FLEET_LINE_ML,
);
map.setPaintProperty('fleet-circles-ml', 'line-width',
fleetHighlightExpr !== false
? ['case', fleetHighlightExpr, FLEET_LINE_W_HL, FLEET_LINE_W_NORMAL] as never
: FLEET_LINE_W_NORMAL,
);
}
}
} catch {
// ignore
}
kickRepaint(map);
}, [projection, hoveredFleetMmsiList, hoveredFleetOwnerKeyList, hoveredPairMmsiList, overlays.fcLines, overlays.fleetCircles]);
// paintStateRef를 최신 콜백으로 유지
useEffect(() => {
paintStateRef.current = updateFcFleetPaintStates;
}, [updateFcFleetPaintStates]);
// paint state 동기화
useEffect(() => {
updateFcFleetPaintStates();
}, [mapSyncEpoch, hoveredFleetMmsiList, hoveredFleetOwnerKeyList, hoveredPairMmsiList, projection, overlays.fcLines, overlays.fleetCircles, updateFcFleetPaintStates, fcLinks, fleetCircles]);
// ── Breathing animation ──
useEffect(() => {
const map = mapRef.current;
const hasFleetHover = hoveredFleetOwnerKeyList.length > 0 || hoveredFleetMmsiList.length > 0;
const hasFcHover = hoveredPairMmsiList.length > 0 || hoveredFleetMmsiList.length > 0;
if (!map || (!hasFleetHover && !hasFcHover) || projection !== 'globe') {
if (breatheRafRef.current) cancelAnimationFrame(breatheRafRef.current);
breatheRafRef.current = 0;
return;
}
const fleetAwarePairMmsiList = makeUniqueSorted([...hoveredPairMmsiList, ...hoveredFleetMmsiList]);
const fcEndpointHighlightExpr = fleetAwarePairMmsiList.length > 0
? makeMmsiAnyEndpointExpr('fcMmsi', 'otherMmsi', fleetAwarePairMmsiList)
: false;
const fleetOwnerMatchExpr = hoveredFleetOwnerKeyList.length > 0 ? makeFleetOwnerMatchExpr(hoveredFleetOwnerKeyList) : false;
const fleetMemberExpr = hoveredFleetMmsiList.length > 0 ? makeFleetMemberMatchExpr(hoveredFleetMmsiList) : false;
const fleetHighlightExpr =
hoveredFleetOwnerKeyList.length > 0 || hoveredFleetMmsiList.length > 0
? (['any', fleetOwnerMatchExpr, fleetMemberExpr] as never)
: false;
const animate = () => {
if (!map.isStyleLoaded()) {
breatheRafRef.current = requestAnimationFrame(animate);
return;
}
const t = (Math.sin(Date.now() / BREATHE_PERIOD_MS * Math.PI * 2) + 1) / 2;
try {
if (map.getLayer('fc-lines-ml') && fcEndpointHighlightExpr !== false) {
const hlW = FC_LINE_W_HL + t * BREATHE_AMP;
map.setPaintProperty('fc-lines-ml', 'line-width',
['case', fcEndpointHighlightExpr, hlW, FC_LINE_W_NORMAL] as never);
}
if (map.getLayer('fleet-circles-ml') && fleetHighlightExpr !== false) {
const hlW = FLEET_LINE_W_HL + t * BREATHE_AMP;
map.setPaintProperty('fleet-circles-ml', 'line-width',
['case', fleetHighlightExpr, hlW, FLEET_LINE_W_NORMAL] as never);
}
} catch {
// ignore
}
breatheRafRef.current = requestAnimationFrame(animate);
};
breatheRafRef.current = requestAnimationFrame(animate);
return () => {
if (breatheRafRef.current) cancelAnimationFrame(breatheRafRef.current);
breatheRafRef.current = 0;
};
}, [hoveredFleetMmsiList, hoveredFleetOwnerKeyList, hoveredPairMmsiList, projection]);
}