/* ============================================================================= * B05_Profile_UI_Drainage_Interact.ts * 배수유역 지도의 포인터 조작 — 휠 확대/축소, 가운데 버튼 팬, 배관 마커 잡기, * 유역 폴리곤 고르기. * * 패널 본체(B05_Profile_UI_Drainage_Panel)가 700줄 한계에 닿아 분리했다. * 화면 변환값(배율·오프셋)은 여전히 본체가 들고 있고 여기서는 접근자로만 읽고 쓴다 — * 그리기·유역 목록·마커 편집기가 같은 값을 보던 구조를 그대로 두기 위함이다. * ========================================================================== */ import { lonLatToScreen, type Normalizer, type ViewState, } from "../B04_PreProcess/B04_PreProcess_UI_MapRender"; import type { DetailBasin } from "../B04_PreProcess/B04_PreProcess_Api_Fetch"; import { pointInRing } from "./B05_Profile_UI_Drainage_Parts"; import type { createPipeEditor } from "./B05_Profile_UI_Drainage_Pipes"; import type { createMapContextMenu } from "@ui/ui_template_context_menu"; /** 이만큼(px) 이하로 움직였다 뗐으면 클릭으로 본다 — 손떨림으로 선택이 안 되는 일을 막는다. */ const CLICK_SLOP_PX = 4; export interface DrainageInteractParams { viewport: HTMLElement; contextMenu: ReturnType; pipeEditor: ReturnType; /** 현재 화면 변환(배율·오프셋·지도 사각형). 본체의 것을 그대로 쓴다. */ currentView: () => ViewState; getScale: () => number; setScale: (value: number) => void; getOffset: () => { x: number; y: number }; setOffset: (x: number, y: number) => void; scheduleDraw: () => void; /** 유역 고르기에 필요한 현재 상태 — 좌표 변환기가 아직 없으면 고르지 않는다. */ getNormalizer: () => Normalizer | null; getBasins: () => ReadonlyArray; getSelectedBasin: () => number | null; /** 유역 강조를 바꾸는 유일한 자리(본체 selectBasin). */ selectBasin: (index: number | null) => void; } /** 뷰포트에 포인터·휠 조작을 붙인다. 반환값은 없다 — 리스너는 뷰포트와 수명이 같다. */ export function bindDrainageInteractions(params: DrainageInteractParams): void { const { viewport, contextMenu, pipeEditor, currentView, scheduleDraw } = params; let dragStart: { x: number; y: number; offsetX: number; offsetY: number } | null = null; /** 좌클릭을 시작한 자리 — 끌지 않고 뗐을 때만 유역 고르기로 본다. */ let basinClickStart: { x: number; y: number } | null = null; /** 마커를 잡은 좌클릭 — 이 경우 유역 고르기로 넘기지 않는다. */ let pipeClickStart: { x: number; y: number } | null = null; viewport.addEventListener( "wheel", (event) => { event.preventDefault(); const prevScale = params.getScale(); // 휠을 **당기면 확대**, 밀면 축소한다(2026-08-02 사용자 지시). B04 2D 지도와 같은 방향이다. const scale = Math.min(16, Math.max(0.5, prevScale * (event.deltaY > 0 ? 1.15 : 0.87))); params.setScale(scale); // 커서 아래 지점을 고정한 채 확대/축소 (B04 지도와 동일 동작). const ratio = scale / prevScale; const rect = viewport.getBoundingClientRect(); const cursorX = event.clientX - rect.left - rect.width / 2; const cursorY = event.clientY - rect.top - rect.height / 2; const offset = params.getOffset(); params.setOffset( cursorX * (1 - ratio) + offset.x * ratio, cursorY * (1 - ratio) + offset.y * ratio, ); scheduleDraw(); }, { passive: false }, ); viewport.addEventListener("pointerdown", (event) => { if (contextMenu.contains(event.target)) return; contextMenu.close(); // 중간 버튼 기본 동작(페이지 자동 스크롤)이 지도 팬과 겹치지 않게 막는다. if (event.button === 1) event.preventDefault(); const rect = viewport.getBoundingClientRect(); // 배관 마커는 **좌클릭으로만** 잡는다 — 우클릭은 메뉴, 가운데 버튼은 팬 전용이다 // (2026-08-01 사용자 지시). if ( event.button === 0 && pipeEditor.handleDown(currentView(), event.clientX - rect.left, event.clientY - rect.top) ) { // 마커를 잡은 좌클릭이므로 유역 고르기로는 넘기지 않는다. pipeClickStart = { x: event.clientX, y: event.clientY }; viewport.setPointerCapture(event.pointerId); return; } // 마커를 못 잡은 좌클릭은 유역 고르기 후보로 기억한다(끌지 않고 뗐을 때만 고른다). if (event.button === 0) basinClickStart = { x: event.clientX, y: event.clientY }; // 팬은 가운데(휠) 버튼 전용이다. 좌버튼은 유역선 핸들·배관 마커를 고르고 끄는 데만 쓴다 // (좌버튼이 팬까지 겸하면 마커를 집으려다 지도가 딸려 움직인다 — 2026-08-01 사용자 지시). // 가운데 버튼이 없는 터치·펜은 그대로 끌어서 팬 할 수 있게 둔다. if (event.pointerType === "mouse" && event.button !== 1) return; const offset = params.getOffset(); dragStart = { x: event.clientX, y: event.clientY, offsetX: offset.x, offsetY: offset.y }; viewport.style.cursor = "grabbing"; viewport.setPointerCapture(event.pointerId); }); viewport.addEventListener("pointermove", (event) => { const rect = viewport.getBoundingClientRect(); // 배관 드래그 중이면 마커 이동(계획선 스냅)만 처리한다. if (pipeEditor.handleMove(currentView(), event.clientX - rect.left, event.clientY - rect.top)) return; if (!dragStart) return; params.setOffset( dragStart.offsetX + event.clientX - dragStart.x, dragStart.offsetY + event.clientY - dragStart.y, ); scheduleDraw(); }); /** 유역 폴리곤을 눌러 고른다. 유역 밖을 누르면 강조를 푼다. */ function pickBasinAt(x: number, y: number): void { const normalizer = params.getNormalizer(); if (!normalizer) return; const view = currentView(); let hit: number | null = null; let smallest = Number.POSITIVE_INFINITY; params.getBasins().forEach((basin) => { if (basin.polygon_lonlat.length < 3) return; const ring = basin.polygon_lonlat.map(([lon, lat]) => lonLatToScreen(normalizer, view, lon, lat), ); if (!pointInRing(ring, x, y)) return; // 겹치면 면적이 작은 쪽을 고른다(안쪽 조각 우선). if (basin.area_m2 < smallest) { smallest = basin.area_m2; hit = basin.index; } }); params.selectBasin(hit === params.getSelectedBasin() ? null : hit); } const stopDragging = (): void => { pipeEditor.handleUp(); dragStart = null; viewport.style.removeProperty("cursor"); }; viewport.addEventListener("pointerup", (event) => { const start = basinClickStart; basinClickStart = null; const dragged = pipeClickStart !== null; pipeClickStart = null; if (!dragged && start && event.button === 0) { const rect = viewport.getBoundingClientRect(); // 끌었으면 지도 조작이지 고르기가 아니다. if (Math.hypot(event.clientX - start.x, event.clientY - start.y) <= CLICK_SLOP_PX) { pickBasinAt(event.clientX - rect.left, event.clientY - rect.top); } } stopDragging(); }); viewport.addEventListener("pointercancel", stopDragging); }