ship-gis/src/areaSearch/hooks/useZoneDraw.js
LHT 4945606c1c feat: STS 분석 기능 구현 및 항적분석 고도화
- STS(Ship-to-Ship) 접촉 분석 기능 전체 구현
  - API 연동 (vessel-contacts), 스토어, 레이어 훅, 레이어 레지스트리
  - 접촉 쌍 그룹핑, 그룹 카드 목록, 상세 모달 (그리드 레이아웃)
  - ScatterplotLayer 접촉 포인트 + 위험도 색상
- 항적분석 탭 UI 분리 (구역분석 / STS분석)
  - AreaSearchPage → AreaSearchTab, StsAnalysisTab 추출
  - 탭 전환 시 결과 초기화 확인, 구역 클리어
- 지도 호버 하이라이트 구현 (구역분석 + STS)
  - MapContainer pointermove에 STS 레이어 ID 핸들러 추가
  - STS 쌍 항적 동시 하이라이트 (vesselId → groupIndex 매핑)
  - 목록↔지도 호버 연동 자동 스크롤
  - pickingRadius 12→20 확대
- 재생 컨트롤러(AreaSearchTimeline) STS 지원
  - 항적/궤적 토글 activeTab 기반 스토어 분기
  - 닫기 시 양쪽 스토어 + 레이어 정리
- 패널 닫기 초기화 수정 (isOpen 감지, clearResults로 탭 보존)
- 조회 중 로딩 오버레이 (LoadingOverlay 공통 컴포넌트)
- 항적분석 다중 방문 대응, 선박 상세 모달, 구역 편집 기능
- trackLayer updateTriggers Set 직렬화, highlightedVesselIds 지원

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-12 06:20:46 +09:00

262 lines
8.0 KiB
JavaScript

/**
* 구역 그리기 OpenLayers Draw 인터랙션 훅
*
* - activeDrawType 변경 시 Draw 인터랙션 활성화
* - Polygon / Box / Circle 그리기
* - drawend → EPSG:3857→4326 변환 → addZone()
* - ESC 키로 그리기 취소
* - 구역별 색상 스타일 (ZONE_COLORS)
*/
import { useEffect, useRef, useCallback } from 'react';
import VectorSource from 'ol/source/Vector';
import VectorLayer from 'ol/layer/Vector';
import { Draw } from 'ol/interaction';
import { createBox } from 'ol/interaction/Draw';
import { Style, Fill, Stroke } from 'ol/style';
import { transform } from 'ol/proj';
import { fromCircle } from 'ol/geom/Polygon';
import { useMapStore } from '../../stores/mapStore';
import { useAreaSearchStore } from '../stores/areaSearchStore';
import { ZONE_COLORS, ZONE_DRAW_TYPES } from '../types/areaSearch.types';
import { setZoneSource, getZoneSource, setZoneLayer, getZoneLayer } from '../utils/zoneLayerRefs';
/**
* 3857 좌표를 4326 좌표로 변환하고 폐곡선 보장
*/
function toWgs84Polygon(coords3857) {
const coords4326 = coords3857.map((c) => transform(c, 'EPSG:3857', 'EPSG:4326'));
// 폐곡선 보장 (첫점 == 끝점)
if (coords4326.length > 0) {
const first = coords4326[0];
const last = coords4326[coords4326.length - 1];
if (first[0] !== last[0] || first[1] !== last[1]) {
coords4326.push([...first]);
}
}
return coords4326;
}
/**
* 구역 인덱스에 맞는 OL 스타일 생성
*/
function createZoneStyle(index) {
const color = ZONE_COLORS[index] || ZONE_COLORS[0];
return new Style({
fill: new Fill({ color: `rgba(${color.fill.join(',')})` }),
stroke: new Stroke({ color: `rgba(${color.stroke.join(',')})`, width: 2 }),
});
}
export default function useZoneDraw() {
const map = useMapStore((s) => s.map);
const drawRef = useRef(null);
const mapRef = useRef(null);
// map ref 동기화 (클린업에서 사용)
useEffect(() => {
mapRef.current = map;
}, [map]);
// 맵 준비 시 레이어 설정
useEffect(() => {
if (!map) return;
const source = new VectorSource({ wrapX: false });
const layer = new VectorLayer({
source,
zIndex: 55,
});
map.addLayer(layer);
setZoneSource(source);
setZoneLayer(layer);
// 기존 zones가 있으면 동기화
const { zones } = useAreaSearchStore.getState();
zones.forEach((zone) => {
if (!zone.olFeature) return;
zone.olFeature.setStyle(createZoneStyle(zone.colorIndex));
source.addFeature(zone.olFeature);
});
return () => {
if (drawRef.current) {
map.removeInteraction(drawRef.current);
drawRef.current = null;
}
map.removeLayer(layer);
setZoneSource(null);
setZoneLayer(null);
};
}, [map]);
// 스토어의 zones 변경 → OL feature 동기화
useEffect(() => {
const unsub = useAreaSearchStore.subscribe(
(s) => s.zones,
(zones) => {
const source = getZoneSource();
if (!source) return;
source.clear();
zones.forEach((zone) => {
if (!zone.olFeature) return;
zone.olFeature.setStyle(createZoneStyle(zone.colorIndex));
source.addFeature(zone.olFeature);
});
},
);
return unsub;
}, []);
// showZones 변경 → 레이어 표시/숨김
useEffect(() => {
const unsub = useAreaSearchStore.subscribe(
(s) => s.showZones,
(show) => {
const layer = getZoneLayer();
if (layer) layer.setVisible(show);
},
);
return unsub;
}, []);
// Draw 인터랙션 생성 함수
const setupDraw = useCallback((currentMap, drawType) => {
// 기존 인터랙션 제거
if (drawRef.current) {
currentMap.removeInteraction(drawRef.current);
drawRef.current = null;
}
if (!drawType) return;
const source = getZoneSource();
if (!source) return;
// source를 Draw에 전달하지 않음
// OL Draw는 drawend 이벤트 후에 source.addFeature()를 자동 호출하는데,
// 우리 drawend 핸들러의 addZone() → subscription → source.addFeature() 와 충돌하여
// "feature already added" 에러 발생. source를 제거하면 자동 추가가 비활성화됨.
let draw;
if (drawType === ZONE_DRAW_TYPES.BOX) {
draw = new Draw({ type: 'Circle', geometryFunction: createBox() });
} else if (drawType === ZONE_DRAW_TYPES.CIRCLE) {
draw = new Draw({ type: 'Circle' });
} else {
draw = new Draw({ type: 'Polygon' });
}
draw.on('drawend', (evt) => {
const feature = evt.feature;
let geom = feature.getGeometry();
const typeName = drawType;
// Circle → Polygon 변환 (center/radius 보존)
let circleMeta = null;
if (drawType === ZONE_DRAW_TYPES.CIRCLE) {
circleMeta = { center: geom.getCenter(), radius: geom.getRadius() };
const polyGeom = fromCircle(geom, 64);
feature.setGeometry(polyGeom);
geom = polyGeom;
}
// EPSG:3857 → 4326 좌표 추출
const coords3857 = geom.getCoordinates()[0];
const coordinates = toWgs84Polygon(coords3857);
// 최소 4점 확인
if (coordinates.length < 4) {
return;
}
const { zones } = useAreaSearchStore.getState();
const index = zones.length;
const style = createZoneStyle(index);
feature.setStyle(style);
// source에 직접 추가 (즉시 표시, Draw의 자동 추가를 대체)
source.addFeature(feature);
// 상태 업데이트를 다음 틱으로 지연
// drawend 이벤트 처리 중에 Draw를 동기적으로 제거하면,
// OL 내부 이벤트 체인이 완료되기 전에 DragPan이 이벤트를 가로채서
// 지도가 마우스를 따라 움직이는 문제가 발생함.
// setTimeout으로 OL 이벤트 처리가 완료된 후 안전하게 제거.
setTimeout(() => {
useAreaSearchStore.getState().addZone({
type: typeName,
source: 'draw',
coordinates,
olFeature: feature,
circleMeta,
});
// addZone → activeDrawType: null → subscription → removeInteraction
}, 0);
});
currentMap.addInteraction(draw);
drawRef.current = draw;
}, []);
// activeDrawType 변경 → Draw 인터랙션 설정
useEffect(() => {
if (!map) return;
const unsub = useAreaSearchStore.subscribe(
(s) => s.activeDrawType,
(drawType) => {
setupDraw(map, drawType);
},
);
// 현재 activeDrawType이 이미 설정되어 있으면 즉시 적용
const { activeDrawType } = useAreaSearchStore.getState();
if (activeDrawType) {
setupDraw(map, activeDrawType);
}
return () => {
unsub();
// 구독 해제 시 Draw 인터랙션도 제거
if (drawRef.current && mapRef.current) {
mapRef.current.removeInteraction(drawRef.current);
drawRef.current = null;
}
};
}, [map, setupDraw]);
// ESC 키로 그리기 취소
useEffect(() => {
const handleKeyDown = (e) => {
if (e.key === 'Escape') {
const { activeDrawType } = useAreaSearchStore.getState();
if (activeDrawType) {
useAreaSearchStore.getState().setActiveDrawType(null);
}
}
};
window.addEventListener('keydown', handleKeyDown);
return () => window.removeEventListener('keydown', handleKeyDown);
}, []);
// 구역 삭제 시 OL feature도 source에서 제거 (zones 감소)
useEffect(() => {
const unsub = useAreaSearchStore.subscribe(
(s) => s.zones,
(zones, prevZones) => {
if (!prevZones || zones.length >= prevZones.length) return;
const source = getZoneSource();
if (!source) return;
const currentIds = new Set(zones.map((z) => z.id));
prevZones.forEach((z) => {
if (!currentIds.has(z.id) && z.olFeature) {
try { source.removeFeature(z.olFeature); } catch { /* already removed */ }
}
});
},
);
return unsub;
}, []);
}