259 lines
7.8 KiB
JavaScript
259 lines
7.8 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';
|
||
|
|
|
||
|
|
/**
|
||
|
|
* 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;
|
||
|
|
}, []);
|
||
|
|
}
|