/** * 사각형(Box) 리사이즈 커스텀 인터랙션 * * OL Modify는 사각형 제약을 지원하지 않으므로 PointerInteraction을 확장. * - 모서리 드래그: 대각 꼭짓점 고정, 자유 리사이즈 * - 변 드래그: 반대쪽 변 고정, 1축 리사이즈 */ import PointerInteraction from 'ol/interaction/Pointer'; import type Feature from 'ol/Feature'; import type { Polygon } from 'ol/geom'; import type MapBrowserEvent from 'ol/MapBrowserEvent'; import type OlMap from 'ol/Map'; import type { Coordinate } from 'ol/coordinate'; const CORNER_TOLERANCE = 16; const EDGE_TOLERANCE = 12; /** 점(p)과 선분(a-b) 사이 최소 픽셀 거리 */ function pointToSegmentDist(p: number[], a: number[], b: number[]): number { 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)); } interface BoxResizeInteractionOptions { feature: Feature; onResize?: (feature: Feature) => void; } interface HandleResult { cursor: string; } interface BBox { minX: number; maxX: number; minY: number; maxY: number; } export default class BoxResizeInteraction extends PointerInteraction { private feature_: Feature; private onResize_: ((feature: Feature) => void) | null; // corner mode private mode_: 'corner' | 'edge' | null; private anchorCoord_: Coordinate | null; // edge mode private edgeIndex_: number | null; private bbox_: BBox | null; constructor(options: BoxResizeInteractionOptions) { super({ handleDownEvent: (evt: MapBrowserEvent) => BoxResizeInteraction.prototype._handleDown.call(this, evt), handleDragEvent: (evt: MapBrowserEvent) => BoxResizeInteraction.prototype._handleDrag.call(this, evt), handleUpEvent: () => BoxResizeInteraction.prototype._handleUp.call(this), }); this.feature_ = options.feature; this.onResize_ = options.onResize || null; // corner mode this.mode_ = null; // 'corner' | 'edge' this.anchorCoord_ = null; // edge mode this.edgeIndex_ = null; this.bbox_ = null; } private _handleDown(evt: MapBrowserEvent): boolean { const pixel = evt.pixel as unknown as number[]; const coords = this.feature_.getGeometry()!.getCoordinates()[0]; // 1. 모서리 감지 (우선) for (let i = 0; i < 4; i++) { const vp = evt.map.getPixelFromCoordinate(coords[i]); if (Math.hypot(pixel[0] - vp[0], pixel[1] - vp[1]) < CORNER_TOLERANCE) { this.mode_ = 'corner'; this.anchorCoord_ = coords[(i + 2) % 4]; return true; } } // 2. 변 감지 for (let i = 0; i < 4; i++) { const j = (i + 1) % 4; const p1 = evt.map.getPixelFromCoordinate(coords[i]); const p2 = evt.map.getPixelFromCoordinate(coords[j]); if (pointToSegmentDist(pixel, p1 as unknown as number[], p2 as unknown as number[]) < EDGE_TOLERANCE) { this.mode_ = 'edge'; this.edgeIndex_ = i; const xs = coords.slice(0, 4).map((c: Coordinate) => c[0]); const ys = coords.slice(0, 4).map((c: Coordinate) => c[1]); this.bbox_ = { minX: Math.min(...xs), maxX: Math.max(...xs), minY: Math.min(...ys), maxY: Math.max(...ys), }; return true; } } return false; } private _handleDrag(evt: MapBrowserEvent): void { const coord = evt.coordinate; if (this.mode_ === 'corner') { const anchor = this.anchorCoord_!; const minX = Math.min(coord[0], anchor[0]); const maxX = Math.max(coord[0], anchor[0]); const minY = Math.min(coord[1], anchor[1]); const maxY = Math.max(coord[1], anchor[1]); this.feature_.getGeometry()!.setCoordinates([[ [minX, maxY], [maxX, maxY], [maxX, minY], [minX, minY], [minX, maxY], ]]); } else if (this.mode_ === 'edge') { let { minX, maxX, minY, maxY } = this.bbox_!; // Edge 0: top(TL->TR), 1: right(TR->BR), 2: bottom(BR->BL), 3: left(BL->TL) switch (this.edgeIndex_) { case 0: maxY = coord[1]; break; case 1: maxX = coord[0]; break; case 2: minY = coord[1]; break; case 3: minX = coord[0]; break; } const x1 = Math.min(minX, maxX), x2 = Math.max(minX, maxX); const y1 = Math.min(minY, maxY), y2 = Math.max(minY, maxY); this.feature_.getGeometry()!.setCoordinates([[ [x1, y2], [x2, y2], [x2, y1], [x1, y1], [x1, y2], ]]); } } private _handleUp(): boolean { if (this.mode_) { this.mode_ = null; this.anchorCoord_ = null; this.edgeIndex_ = null; this.bbox_ = null; if (this.onResize_) this.onResize_(this.feature_); return true; } return false; } /** * 호버 감지: 픽셀이 리사이즈 핸들 위인지 확인 */ isOverHandle(map: OlMap, pixel: number[]): HandleResult | null { const coords = this.feature_.getGeometry()!.getCoordinates()[0]; // 모서리 감지 const cornerCursors = ['nwse-resize', 'nesw-resize', 'nwse-resize', 'nesw-resize']; for (let i = 0; i < 4; i++) { const vp = map.getPixelFromCoordinate(coords[i]); if (Math.hypot(pixel[0] - vp[0], pixel[1] - vp[1]) < CORNER_TOLERANCE) { return { cursor: cornerCursors[i] }; } } // 변 감지 const edgeCursors = ['ns-resize', 'ew-resize', 'ns-resize', 'ew-resize']; for (let i = 0; i < 4; i++) { const j = (i + 1) % 4; const p1 = map.getPixelFromCoordinate(coords[i]); const p2 = map.getPixelFromCoordinate(coords[j]); if (pointToSegmentDist(pixel, p1 as unknown as number[], p2 as unknown as number[]) < EDGE_TOLERANCE) { return { cursor: edgeCursors[i] }; } } return null; } }