/** * 구역 편집 인터랙션 훅 * * - 맵 클릭으로 구역 선택/해제 * - 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]); }