ship-gis/src/areaSearch/hooks/useZoneDraw.js

259 lines
7.8 KiB
JavaScript
Raw Normal View 히스토리

feat: 항적분석(구역 검색) 기능 구현 구역 기반 선박 항적 검색 기능 추가. 사용자가 지도에 최대 3개 구역을 그리고 ANY/ALL/SEQUENTIAL 조건으로 해당 구역을 통과한 선박의 항적을 조회·재생할 수 있다. 신규 패키지 (src/areaSearch/): - stores: areaSearchStore, areaSearchAnimationStore (재생 제어) - services: areaSearchApi (REST API + hitDetails 타임스탬프/위치 보간) - components: AreaSearchPage, ZoneDrawPanel, AreaSearchTimeline, AreaSearchTooltip - hooks: useAreaSearchLayer (Deck.gl 레이어), useZoneDraw (OL Draw) - utils: areaSearchLayerRegistry, csvExport (BOM+UTF-8 엑셀 호환) - types: areaSearch.types (상수, 색상, 모드) 주요 기능: - 폴리곤/사각형/원 구역 그리기 + 드래그 순서 변경 - 구역별 색상 구분 (빨강/청록/황색) - 시간 기반 애니메이션 재생 (TripsLayer 궤적 + 가상선박 이동) - 선종/개별 선박 필터링, 항적 표시/궤적 표시 토글 - 호버 툴팁 (국기 SVG, 구역별 진입/진출 시각·위치) - CSV 내보내기 (신호원, 식별번호, 국적 ISO 변환, 구역 통과 정보) 기존 파일 수정: - SideNav/Sidebar: gnb8 '항적분석' 메뉴 활성화 - useShipLayer: areaSearch 레이어 병합 - MapContainer: useAreaSearchLayer 훅 + 호버 핸들러 + 타임라인 렌더링 - trackLayer: layerIds 파라미터 추가 (area search/track query 레이어 ID 분리) - ShipLegend: 항적분석 모드 선종 카운트 지원 - countryCodeUtils: MMSI MID→ISO alpha-2 매핑 추가 Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-10 12:29:31 +09:00
/**
* 구역 그리기 OpenLayers Draw 인터랙션
*
* - activeDrawType 변경 Draw 인터랙션 활성화
* - Polygon / Box / Circle 그리기
* - drawend EPSG:38574326 변환 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';
/**
* 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 sourceRef = useRef(null);
const layerRef = useRef(null);
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);
sourceRef.current = source;
layerRef.current = 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);
sourceRef.current = null;
layerRef.current = null;
};
}, [map]);
// 스토어의 zones 변경 → OL feature 동기화
useEffect(() => {
const unsub = useAreaSearchStore.subscribe(
(s) => s.zones,
(zones) => {
const source = sourceRef.current;
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) => {
if (layerRef.current) layerRef.current.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 = sourceRef.current;
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 변환
if (drawType === ZONE_DRAW_TYPES.CIRCLE) {
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,
});
// 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 = sourceRef.current;
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;
}, []);
}