## 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>
356 lines
13 KiB
TypeScript
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]);
|
|
}
|