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

514 lines
17 KiB
JavaScript
Raw Normal View 히스토리

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