ship-gis/src/areaSearch/interactions/BoxResizeInteraction.ts
htlee 6e3ad9e0d8 chore: JavaScript → TypeScript 전환 완료 (77개 파일)
JS/JSX 77개 파일을 TS/TSX로 전환하고 JS 원본을 삭제.
- stores 7개, map core 6개, hooks 4개 등 전체 모듈 전환
- TypeScript strict 모드, OL/Deck.gl 타입 적용
- .gitignore에서 TS/TSX 무시 규칙 제거
- pre-commit hook: .js,.jsx → .ts,.tsx 확장자 변경
- tsc --noEmit 0 에러, ESLint 0 에러, yarn build 성공

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-15 10:28:27 +09:00

176 lines
5.9 KiB
TypeScript

/**
* 사각형(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<Polygon>;
onResize?: (feature: Feature<Polygon>) => void;
}
interface HandleResult {
cursor: string;
}
interface BBox {
minX: number;
maxX: number;
minY: number;
maxY: number;
}
export default class BoxResizeInteraction extends PointerInteraction {
private feature_: Feature<Polygon>;
private onResize_: ((feature: Feature<Polygon>) => 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<PointerEvent>) => BoxResizeInteraction.prototype._handleDown.call(this, evt),
handleDragEvent: (evt: MapBrowserEvent<PointerEvent>) => 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<PointerEvent>): 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<PointerEvent>): 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;
}
}