- 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>
514 lines
17 KiB
JavaScript
514 lines
17 KiB
JavaScript
/**
|
|
* 구역 편집 인터랙션 훅
|
|
*
|
|
* - 맵 클릭으로 구역 선택/해제
|
|
* - Polygon: OL Modify (꼭짓점 드래그, 변 중점 삽입) + 우클릭 꼭짓점 삭제
|
|
* - Box: BoxResizeInteraction (모서리 드래그, 직사각형 유지)
|
|
* - Circle: CircleResizeInteraction (테두리 드래그, 원형 재생성)
|
|
* - 모든 유형: OL Translate (내부 드래그 → 전체 이동)
|
|
* - ESC: 선택 해제, Delete: 구역 삭제
|
|
* - 편집 완료 시 store 좌표 동기화
|
|
*/
|
|
import { useEffect, useRef, useCallback } from 'react';
|
|
import { Modify, Translate } from 'ol/interaction';
|
|
import Collection from 'ol/Collection';
|
|
import { Style, Fill, Stroke, Circle as CircleStyle } from 'ol/style';
|
|
import { transform } from 'ol/proj';
|
|
import { useMapStore } from '../../stores/mapStore';
|
|
import { useAreaSearchStore } from '../stores/areaSearchStore';
|
|
import { ZONE_COLORS, ZONE_DRAW_TYPES } from '../types/areaSearch.types';
|
|
import { getZoneSource } from '../utils/zoneLayerRefs';
|
|
import BoxResizeInteraction from '../interactions/BoxResizeInteraction';
|
|
import CircleResizeInteraction from '../interactions/CircleResizeInteraction';
|
|
|
|
/** 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;
|
|
}
|
|
|
|
/** 선택된 구역의 하이라이트 스타일 */
|
|
function createSelectedStyle(colorIndex) {
|
|
const color = ZONE_COLORS[colorIndex] || ZONE_COLORS[0];
|
|
return new Style({
|
|
fill: new Fill({ color: `rgba(${color.fill[0]},${color.fill[1]},${color.fill[2]},0.25)` }),
|
|
stroke: new Stroke({
|
|
color: `rgba(${color.stroke[0]},${color.stroke[1]},${color.stroke[2]},1)`,
|
|
width: 3,
|
|
lineDash: [8, 4],
|
|
}),
|
|
});
|
|
}
|
|
|
|
/** Modify 인터랙션의 꼭짓점 핸들 스타일 */
|
|
const MODIFY_STYLE = new Style({
|
|
image: new CircleStyle({
|
|
radius: 6,
|
|
fill: new Fill({ color: '#ffffff' }),
|
|
stroke: new Stroke({ color: '#4a9eff', width: 2 }),
|
|
}),
|
|
});
|
|
|
|
/** 기본 구역 스타일 복원 */
|
|
function createNormalStyle(colorIndex) {
|
|
const color = ZONE_COLORS[colorIndex] || ZONE_COLORS[0];
|
|
return new Style({
|
|
fill: new Fill({ color: `rgba(${color.fill.join(',')})` }),
|
|
stroke: new Stroke({ color: `rgba(${color.stroke.join(',')})`, width: 2 }),
|
|
});
|
|
}
|
|
|
|
/** 호버 스타일 (스트로크 강조) */
|
|
function createHoverStyle(colorIndex) {
|
|
const color = ZONE_COLORS[colorIndex] || ZONE_COLORS[0];
|
|
return new Style({
|
|
fill: new Fill({ color: `rgba(${color.fill.join(',')})` }),
|
|
stroke: new Stroke({
|
|
color: `rgba(${color.stroke[0]},${color.stroke[1]},${color.stroke[2]},1)`,
|
|
width: 3,
|
|
}),
|
|
});
|
|
}
|
|
|
|
/** 점(p)과 선분(a-b) 사이 최소 픽셀 거리 */
|
|
function pointToSegmentDist(p, a, b) {
|
|
const dx = b[0] - a[0];
|
|
const dy = b[1] - a[1];
|
|
const lenSq = dx * dx + dy * dy;
|
|
if (lenSq === 0) return Math.hypot(p[0] - a[0], p[1] - a[1]);
|
|
let t = ((p[0] - a[0]) * dx + (p[1] - a[1]) * dy) / lenSq;
|
|
t = Math.max(0, Math.min(1, t));
|
|
return Math.hypot(p[0] - (a[0] + t * dx), p[1] - (a[1] + t * dy));
|
|
}
|
|
|
|
const HANDLE_TOLERANCE = 12;
|
|
|
|
/** Polygon 꼭짓점/변 근접 검사 */
|
|
function isNearPolygonHandle(map, pixel, feature) {
|
|
const coords = feature.getGeometry().getCoordinates()[0];
|
|
const n = coords.length - 1;
|
|
for (let i = 0; i < n; i++) {
|
|
const vp = map.getPixelFromCoordinate(coords[i]);
|
|
if (Math.hypot(pixel[0] - vp[0], pixel[1] - vp[1]) < HANDLE_TOLERANCE) {
|
|
return true;
|
|
}
|
|
}
|
|
for (let i = 0; i < n; i++) {
|
|
const p1 = map.getPixelFromCoordinate(coords[i]);
|
|
const p2 = map.getPixelFromCoordinate(coords[(i + 1) % n]);
|
|
if (pointToSegmentDist(pixel, p1, p2) < HANDLE_TOLERANCE) {
|
|
return true;
|
|
}
|
|
}
|
|
return false;
|
|
}
|
|
|
|
/** Feature에서 좌표를 추출하여 store에 동기화 */
|
|
function syncZoneToStore(zoneId, feature, zone) {
|
|
const geom = feature.getGeometry();
|
|
const coords3857 = geom.getCoordinates()[0];
|
|
const coords4326 = toWgs84Polygon(coords3857);
|
|
|
|
let circleMeta;
|
|
if (zone.type === ZONE_DRAW_TYPES.CIRCLE && zone.circleMeta) {
|
|
// 폴리곤 중심에서 첫 번째 점까지의 거리로 반지름 재계산
|
|
const center = computeCentroid(coords3857);
|
|
const dx = coords3857[0][0] - center[0];
|
|
const dy = coords3857[0][1] - center[1];
|
|
circleMeta = { center, radius: Math.sqrt(dx * dx + dy * dy) };
|
|
}
|
|
|
|
useAreaSearchStore.getState().updateZoneGeometry(zoneId, coords4326, circleMeta);
|
|
}
|
|
|
|
/** 다각형 중심점 계산 */
|
|
function computeCentroid(coords) {
|
|
let sumX = 0, sumY = 0;
|
|
const n = coords.length - 1; // 마지막(닫힘) 좌표 제외
|
|
for (let i = 0; i < n; i++) {
|
|
sumX += coords[i][0];
|
|
sumY += coords[i][1];
|
|
}
|
|
return [sumX / n, sumY / n];
|
|
}
|
|
|
|
export default function useZoneEdit() {
|
|
const map = useMapStore((s) => s.map);
|
|
const mapRef = useRef(null);
|
|
const modifyRef = useRef(null);
|
|
const translateRef = useRef(null);
|
|
const customResizeRef = useRef(null);
|
|
const selectedCollectionRef = useRef(new Collection());
|
|
const clickListenerRef = useRef(null);
|
|
const contextMenuRef = useRef(null);
|
|
const keydownRef = useRef(null);
|
|
const hoveredZoneIdRef = useRef(null);
|
|
|
|
useEffect(() => { mapRef.current = map; }, [map]);
|
|
|
|
/** 인터랙션 모두 제거 */
|
|
const removeInteractions = useCallback(() => {
|
|
const m = mapRef.current;
|
|
if (!m) return;
|
|
if (modifyRef.current) { m.removeInteraction(modifyRef.current); modifyRef.current = null; }
|
|
if (translateRef.current) { m.removeInteraction(translateRef.current); translateRef.current = null; }
|
|
if (customResizeRef.current) { m.removeInteraction(customResizeRef.current); customResizeRef.current = null; }
|
|
selectedCollectionRef.current.clear();
|
|
}, []);
|
|
|
|
/** 선택된 구역에 대해 인터랙션 설정 */
|
|
const setupInteractions = useCallback((currentMap, zone) => {
|
|
removeInteractions();
|
|
if (!zone || !zone.olFeature) return;
|
|
|
|
const feature = zone.olFeature;
|
|
const collection = selectedCollectionRef.current;
|
|
collection.push(feature);
|
|
|
|
// 선택 스타일 적용
|
|
feature.setStyle(createSelectedStyle(zone.colorIndex));
|
|
|
|
// Translate (모든 유형 공통 — 내부 드래그로 이동)
|
|
const translate = new Translate({ features: collection });
|
|
translate.on('translateend', () => {
|
|
// Circle의 경우 center 업데이트
|
|
if (zone.type === ZONE_DRAW_TYPES.CIRCLE && customResizeRef.current) {
|
|
const coords = feature.getGeometry().getCoordinates()[0];
|
|
const newCenter = computeCentroid(coords);
|
|
customResizeRef.current.setCenter(newCenter);
|
|
}
|
|
syncZoneToStore(zone.id, feature, zone);
|
|
});
|
|
currentMap.addInteraction(translate);
|
|
translateRef.current = translate;
|
|
|
|
// 형상별 편집 인터랙션
|
|
if (zone.type === ZONE_DRAW_TYPES.POLYGON) {
|
|
const modify = new Modify({
|
|
features: collection,
|
|
style: MODIFY_STYLE,
|
|
deleteCondition: () => false, // 기본 삭제 비활성화 (우클릭으로 대체)
|
|
});
|
|
modify.on('modifyend', () => {
|
|
syncZoneToStore(zone.id, feature, zone);
|
|
});
|
|
currentMap.addInteraction(modify);
|
|
modifyRef.current = modify;
|
|
|
|
} else if (zone.type === ZONE_DRAW_TYPES.BOX) {
|
|
const boxResize = new BoxResizeInteraction({
|
|
feature,
|
|
onResize: () => syncZoneToStore(zone.id, feature, zone),
|
|
});
|
|
currentMap.addInteraction(boxResize);
|
|
customResizeRef.current = boxResize;
|
|
|
|
} else if (zone.type === ZONE_DRAW_TYPES.CIRCLE) {
|
|
const center = zone.circleMeta?.center || computeCentroid(feature.getGeometry().getCoordinates()[0]);
|
|
const circleResize = new CircleResizeInteraction({
|
|
feature,
|
|
center,
|
|
onResize: (f) => {
|
|
// 리사이즈 후 circleMeta 업데이트
|
|
const coords = f.getGeometry().getCoordinates()[0];
|
|
const newCenter = computeCentroid(coords);
|
|
const dx = coords[0][0] - newCenter[0];
|
|
const dy = coords[0][1] - newCenter[1];
|
|
const newRadius = Math.sqrt(dx * dx + dy * dy);
|
|
const coords4326 = toWgs84Polygon(coords);
|
|
useAreaSearchStore.getState().updateZoneGeometry(zone.id, coords4326, { center: newCenter, radius: newRadius });
|
|
},
|
|
});
|
|
currentMap.addInteraction(circleResize);
|
|
customResizeRef.current = circleResize;
|
|
}
|
|
}, [removeInteractions]);
|
|
|
|
/** 구역 선택 해제 시 스타일 복원 */
|
|
const restoreStyle = useCallback((zoneId) => {
|
|
const { zones } = useAreaSearchStore.getState();
|
|
const zone = zones.find(z => z.id === zoneId);
|
|
if (zone && zone.olFeature) {
|
|
zone.olFeature.setStyle(createNormalStyle(zone.colorIndex));
|
|
}
|
|
}, []);
|
|
|
|
// selectedZoneId 변경 구독 → 인터랙션 설정/해제
|
|
useEffect(() => {
|
|
if (!map) return;
|
|
|
|
let prevSelectedId = null;
|
|
|
|
const unsub = useAreaSearchStore.subscribe(
|
|
(s) => s.selectedZoneId,
|
|
(zoneId) => {
|
|
// 이전 선택 스타일 복원
|
|
if (prevSelectedId) restoreStyle(prevSelectedId);
|
|
prevSelectedId = zoneId;
|
|
|
|
if (!zoneId) {
|
|
removeInteractions();
|
|
return;
|
|
}
|
|
|
|
const { zones } = useAreaSearchStore.getState();
|
|
const zone = zones.find(z => z.id === zoneId);
|
|
if (zone) {
|
|
setupInteractions(map, zone);
|
|
}
|
|
},
|
|
);
|
|
|
|
return () => {
|
|
unsub();
|
|
if (prevSelectedId) restoreStyle(prevSelectedId);
|
|
removeInteractions();
|
|
};
|
|
}, [map, setupInteractions, removeInteractions, restoreStyle]);
|
|
|
|
// Drawing 모드 진입 시 편집 해제
|
|
useEffect(() => {
|
|
const unsub = useAreaSearchStore.subscribe(
|
|
(s) => s.activeDrawType,
|
|
(drawType) => {
|
|
if (drawType) {
|
|
useAreaSearchStore.getState().deselectZone();
|
|
}
|
|
},
|
|
);
|
|
return unsub;
|
|
}, []);
|
|
|
|
// 맵 singleclick → 구역 선택/해제
|
|
useEffect(() => {
|
|
if (!map) return;
|
|
|
|
const handleClick = (evt) => {
|
|
// Drawing 중이면 무시
|
|
if (useAreaSearchStore.getState().activeDrawType) return;
|
|
|
|
// 구역 그리기 직후 singleclick 방지 (OL singleclick 250ms 지연 레이스 컨디션)
|
|
if (Date.now() - useAreaSearchStore.getState()._lastZoneAddedAt < 500) return;
|
|
|
|
const source = getZoneSource();
|
|
if (!source) return;
|
|
|
|
// 클릭 지점의 feature 탐색
|
|
let clickedZone = null;
|
|
const { zones } = useAreaSearchStore.getState();
|
|
|
|
map.forEachFeatureAtPixel(evt.pixel, (feature) => {
|
|
if (clickedZone) return; // 이미 찾았으면 무시
|
|
const zone = zones.find(z => z.olFeature === feature);
|
|
if (zone) clickedZone = zone;
|
|
}, { layerFilter: (layer) => layer.getSource() === source });
|
|
|
|
const { selectedZoneId } = useAreaSearchStore.getState();
|
|
|
|
if (clickedZone) {
|
|
if (clickedZone.id === selectedZoneId) return; // 이미 선택됨
|
|
|
|
// 결과 표시 중이면 confirmAndClearResults
|
|
if (!useAreaSearchStore.getState().confirmAndClearResults()) return;
|
|
|
|
useAreaSearchStore.getState().selectZone(clickedZone.id);
|
|
} else {
|
|
// 빈 영역 클릭 → 선택 해제
|
|
if (selectedZoneId) {
|
|
useAreaSearchStore.getState().deselectZone();
|
|
}
|
|
}
|
|
};
|
|
|
|
map.on('singleclick', handleClick);
|
|
clickListenerRef.current = handleClick;
|
|
|
|
return () => {
|
|
map.un('singleclick', handleClick);
|
|
clickListenerRef.current = null;
|
|
};
|
|
}, [map]);
|
|
|
|
// 우클릭 꼭짓점 삭제 (Polygon 전용)
|
|
useEffect(() => {
|
|
if (!map) return;
|
|
|
|
const handleContextMenu = (e) => {
|
|
const { selectedZoneId, zones } = useAreaSearchStore.getState();
|
|
if (!selectedZoneId) return;
|
|
|
|
const zone = zones.find(z => z.id === selectedZoneId);
|
|
if (!zone || zone.type !== ZONE_DRAW_TYPES.POLYGON) return;
|
|
|
|
const feature = zone.olFeature;
|
|
if (!feature) return;
|
|
|
|
const geom = feature.getGeometry();
|
|
const coords = geom.getCoordinates()[0];
|
|
const vertexCount = coords.length - 1; // 마지막 닫힘 좌표 제외
|
|
if (vertexCount <= 3) return; // 최소 삼각형 유지
|
|
|
|
const pixel = map.getEventPixel(e);
|
|
const clickCoord = map.getCoordinateFromPixel(pixel);
|
|
|
|
// 가장 가까운 꼭짓점 탐색
|
|
let minDist = Infinity;
|
|
let minIdx = -1;
|
|
for (let i = 0; i < vertexCount; i++) {
|
|
const dx = coords[i][0] - clickCoord[0];
|
|
const dy = coords[i][1] - clickCoord[1];
|
|
const dist = dx * dx + dy * dy;
|
|
if (dist < minDist) { minDist = dist; minIdx = i; }
|
|
}
|
|
|
|
// 픽셀 거리 검증 (10px 이내)
|
|
const vPixel = map.getPixelFromCoordinate(coords[minIdx]);
|
|
if (Math.hypot(pixel[0] - vPixel[0], pixel[1] - vPixel[1]) > 10) return;
|
|
|
|
e.preventDefault();
|
|
|
|
const newCoords = [...coords];
|
|
newCoords.splice(minIdx, 1);
|
|
if (minIdx === 0) {
|
|
newCoords[newCoords.length - 1] = [...newCoords[0]];
|
|
}
|
|
geom.setCoordinates([newCoords]);
|
|
syncZoneToStore(zone.id, feature, zone);
|
|
};
|
|
|
|
const viewport = map.getViewport();
|
|
viewport.addEventListener('contextmenu', handleContextMenu);
|
|
contextMenuRef.current = handleContextMenu;
|
|
|
|
return () => {
|
|
viewport.removeEventListener('contextmenu', handleContextMenu);
|
|
contextMenuRef.current = null;
|
|
};
|
|
}, [map]);
|
|
|
|
// 키보드: ESC → 선택 해제, Delete → 구역 삭제
|
|
useEffect(() => {
|
|
const handleKeyDown = (e) => {
|
|
const { selectedZoneId, activeDrawType } = useAreaSearchStore.getState();
|
|
|
|
if (e.key === 'Escape' && selectedZoneId && !activeDrawType) {
|
|
useAreaSearchStore.getState().deselectZone();
|
|
}
|
|
|
|
if ((e.key === 'Delete' || e.key === 'Backspace') && selectedZoneId && !activeDrawType) {
|
|
useAreaSearchStore.getState().deselectZone();
|
|
useAreaSearchStore.getState().removeZone(selectedZoneId);
|
|
}
|
|
};
|
|
|
|
window.addEventListener('keydown', handleKeyDown);
|
|
keydownRef.current = handleKeyDown;
|
|
|
|
return () => {
|
|
window.removeEventListener('keydown', handleKeyDown);
|
|
keydownRef.current = null;
|
|
};
|
|
}, []);
|
|
|
|
// pointermove → 호버 피드백 (커서 + 스타일)
|
|
useEffect(() => {
|
|
if (!map) return;
|
|
|
|
const viewport = map.getViewport();
|
|
|
|
const handlePointerMove = (evt) => {
|
|
if (evt.dragging) return;
|
|
|
|
// Drawing 중이면 호버 해제
|
|
if (useAreaSearchStore.getState().activeDrawType) {
|
|
if (hoveredZoneIdRef.current) {
|
|
restoreStyle(hoveredZoneIdRef.current);
|
|
hoveredZoneIdRef.current = null;
|
|
}
|
|
viewport.style.cursor = '';
|
|
return;
|
|
}
|
|
|
|
const source = getZoneSource();
|
|
if (!source) return;
|
|
|
|
const { selectedZoneId, zones } = useAreaSearchStore.getState();
|
|
|
|
// 1. 선택된 구역 — 리사이즈 핸들 / 내부 커서
|
|
if (selectedZoneId) {
|
|
const zone = zones.find(z => z.id === selectedZoneId);
|
|
if (zone && zone.olFeature) {
|
|
// Box/Circle: isOverHandle
|
|
if (customResizeRef.current && customResizeRef.current.isOverHandle) {
|
|
const handle = customResizeRef.current.isOverHandle(map, evt.pixel);
|
|
if (handle) {
|
|
viewport.style.cursor = handle.cursor;
|
|
return;
|
|
}
|
|
}
|
|
|
|
// Polygon: 꼭짓점/변 근접
|
|
if (zone.type === ZONE_DRAW_TYPES.POLYGON) {
|
|
if (isNearPolygonHandle(map, evt.pixel, zone.olFeature)) {
|
|
viewport.style.cursor = 'crosshair';
|
|
return;
|
|
}
|
|
}
|
|
|
|
// 선택된 구역 내부 → move
|
|
let overSelected = false;
|
|
map.forEachFeatureAtPixel(evt.pixel, (feature) => {
|
|
if (feature === zone.olFeature) overSelected = true;
|
|
}, { layerFilter: (l) => l.getSource() === source });
|
|
|
|
if (overSelected) {
|
|
viewport.style.cursor = 'move';
|
|
return;
|
|
}
|
|
}
|
|
}
|
|
|
|
// 2. 비선택 구역 호버
|
|
let hoveredZone = null;
|
|
map.forEachFeatureAtPixel(evt.pixel, (feature) => {
|
|
if (hoveredZone) return;
|
|
const zone = zones.find(z => z.olFeature === feature && z.id !== selectedZoneId);
|
|
if (zone) hoveredZone = zone;
|
|
}, { layerFilter: (l) => l.getSource() === source });
|
|
|
|
if (hoveredZone) {
|
|
viewport.style.cursor = 'pointer';
|
|
if (hoveredZoneIdRef.current !== hoveredZone.id) {
|
|
if (hoveredZoneIdRef.current) restoreStyle(hoveredZoneIdRef.current);
|
|
hoveredZoneIdRef.current = hoveredZone.id;
|
|
hoveredZone.olFeature.setStyle(createHoverStyle(hoveredZone.colorIndex));
|
|
}
|
|
} else {
|
|
viewport.style.cursor = '';
|
|
if (hoveredZoneIdRef.current) {
|
|
restoreStyle(hoveredZoneIdRef.current);
|
|
hoveredZoneIdRef.current = null;
|
|
}
|
|
}
|
|
};
|
|
|
|
map.on('pointermove', handlePointerMove);
|
|
|
|
return () => {
|
|
map.un('pointermove', handlePointerMove);
|
|
if (hoveredZoneIdRef.current) {
|
|
restoreStyle(hoveredZoneIdRef.current);
|
|
hoveredZoneIdRef.current = null;
|
|
}
|
|
viewport.style.cursor = '';
|
|
};
|
|
}, [map, restoreStyle]);
|
|
}
|