- 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>
262 lines
8.0 KiB
JavaScript
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;
|
|
}, []);
|
|
}
|