/* ============================================================================= * B05_Profile_UI_RouteEdit_Input.ts * 노선 편집 모달의 **지도 조작** — 휠 확대/축소와 가운데 버튼 팬. * * ⚠ 배수유역도(`B05_Profile_UI_Drainage_Interact.ts`)와 **같은 동작**이어야 한다 * (2026-09-07 사용자 지시 ⑤). 한쪽만 고치면 두 지도가 서로 다르게 움직인다. * · 휠을 **당기면 확대**(`deltaY > 0` → 확대) · 계수 1.15 / 0.87 * · 배율 상한은 「화면 폭 16m」로 계산(`computeMaxScale`), 하한 0.5 * · **팬은 가운데(휠) 버튼 전용** — 왼쪽 버튼이 팬까지 겸하면 노드를 집으려다 지도가 * 딸려 움직인다. 가운데 버튼이 없는 터치·펜은 그대로 끌어서 팬 한다. * * ⚠ 커서 고정은 **화면 중심 기준**으로 셈한다 — `affineOf` 가 `centerX*(1−scale)` 을 품고 * 있어 좌상단 기준으로 셈하면 확대할수록 커서 아래 지점이 밀린다(옛 모달의 버그). * ========================================================================== */ import { computeMaxScale, type ViewState } from "../B04_PreProcess/B04_PreProcess_UI_MapRender"; import type { VWorldMeta } from "../B04_PreProcess/B04_PreProcess_Api_Fetch"; /** 배수유역도와 같은 값 — 한 번에 얼마나 확대·축소할지. */ const ZOOM_IN_FACTOR = 1.15; const ZOOM_OUT_FACTOR = 0.87; /** 배율 하한 — 배수유역도와 같다(전체보다 조금 더 뒤로 뺄 수 있게). */ const MIN_SCALE = 0.5; /** 배율 상한을 못 구할 때 쓸 값 — 배수유역도와 같은 자리에 같은 수. */ const MAX_SCALE_FALLBACK = 16; export interface RouteEditNavigationParams { canvas: HTMLCanvasElement; getView: () => ViewState; setView: (next: ViewState) => void; getMeta: () => VWorldMeta | null; draw: () => void; } /** 캔버스에 휠 확대·가운데 버튼 팬을 붙인다. 리스너는 캔버스와 수명이 같다. */ export function bindRouteEditNavigation(params: RouteEditNavigationParams): void { const { canvas, getView, setView, getMeta, draw } = params; let dragStart: { x: number; y: number; offsetX: number; offsetY: number } | null = null; canvas.addEventListener( "wheel", (event) => { event.preventDefault(); const view = getView(); const maxScale = computeMaxScale( getMeta(), view.mapRect.width, view.width, MAX_SCALE_FALLBACK, ); const factor = event.deltaY > 0 ? ZOOM_IN_FACTOR : ZOOM_OUT_FACTOR; const scale = Math.min(maxScale, Math.max(MIN_SCALE, view.scale * factor)); const ratio = scale / view.scale; const rect = canvas.getBoundingClientRect(); // 커서 자리를 **화면 중심 기준**으로 잡는다 — 그래야 그 지점이 제자리에 남는다. const cursorX = event.clientX - rect.left - rect.width / 2; const cursorY = event.clientY - rect.top - rect.height / 2; setView({ ...view, scale, offsetX: cursorX * (1 - ratio) + view.offsetX * ratio, offsetY: cursorY * (1 - ratio) + view.offsetY * ratio, }); draw(); }, { passive: false }, ); canvas.addEventListener("pointerdown", (event) => { // 가운데 버튼 기본 동작(페이지 자동 스크롤)이 팬과 겹치지 않게 막는다. if (event.button === 1) event.preventDefault(); // 마우스는 가운데 버튼만 팬이다 — 왼쪽 버튼은 노드·손잡이 조작 몫. if (event.pointerType === "mouse" && event.button !== 1) return; const view = getView(); dragStart = { x: event.clientX, y: event.clientY, offsetX: view.offsetX, offsetY: view.offsetY, }; canvas.style.cursor = "grabbing"; canvas.setPointerCapture(event.pointerId); }); canvas.addEventListener("pointermove", (event) => { if (!dragStart) return; setView({ ...getView(), offsetX: dragStart.offsetX + event.clientX - dragStart.x, offsetY: dragStart.offsetY + event.clientY - dragStart.y, }); draw(); }); const stop = (): void => { if (!dragStart) return; dragStart = null; canvas.style.removeProperty("cursor"); }; canvas.addEventListener("pointerup", stop); canvas.addEventListener("pointercancel", stop); } /* ── 집기(hit test) — 화면 좌표에서 무엇을 잡았나. 모두 순수 함수다 ─────────────── */ export type ScreenOf = (point: [number, number]) => [number, number]; /** 화면 좌표에 가장 가까운 노드. 문턱 밖이면 -1. */ export function nodeAtScreen( points: Array<[number, number]>, toScreen: ScreenOf, px: number, py: number, hitPx: number, ): number { let best = -1; let bestDistance = hitPx; points.forEach((vertex, index) => { const [x, y] = toScreen(vertex); const distance = Math.hypot(x - px, y - py); if (distance <= bestDistance) { bestDistance = distance; best = index; } }); return best; } /** 화면 좌표에 가장 가까운 **곡선 손잡이**(접선점). 없으면 null. * * 곡선 **전부**를 본다 — 접선점은 늘 그려지므로 늘 잡혀야 한다. 노드를 못 집던 문제는 * 부르는 쪽에서 **노드를 먼저** 보는 것으로 풀었다(2026-09-07). * 잡은 것은 곡선 목록 자리가 아니라 **노드 번호**로 돌려준다: 목록은 고칠 때마다 새로 나므로 * 자리로 들면 끄는 도중 엉뚱한 곡선을 가리킨다. */ export function handleAtScreen( curves: Array<{ node_first: number; start: [number, number]; end: [number, number] }>, toScreen: ScreenOf, px: number, py: number, hitPx: number, ): { node: number; end: "start" | "end" } | null { let best: { node: number; end: "start" | "end" } | null = null; let bestDistance = hitPx; curves.forEach((curve) => { (["start", "end"] as const).forEach((which) => { const point = which === "start" ? curve.start : curve.end; const [x, y] = toScreen([point[0], point[1]]); const distance = Math.hypot(x - px, y - py); if (distance <= bestDistance) { bestDistance = distance; best = { node: curve.node_first, end: which }; } }); }); return best; } /** 두 노드 사이 선분 중 클릭에 가장 가까운 것 — 새 노드를 끼울 자리. 없으면 -1. */ export function segmentAtScreen( points: Array<[number, number]>, toScreen: ScreenOf, px: number, py: number, maxPx: number, ): number { let best = -1; let bestDistance = maxPx; for (let index = 0; index < points.length - 1; index += 1) { const [ax, ay] = toScreen(points[index]); const [bx, by] = toScreen(points[index + 1]); const dx = bx - ax; const dy = by - ay; const lengthSquared = dx * dx + dy * dy || 1; const t = Math.max(0, Math.min(1, ((px - ax) * dx + (py - ay) * dy) / lengthSquared)); const distance = Math.hypot(ax + t * dx - px, ay + t * dy - py); if (distance < bestDistance) { bestDistance = distance; best = index; } } return best; } /** 등고선을 보일 화면 사각형 — 노선 경계에 `bandM` 를 두른 것. 노선이 없으면 null. * * **매 프레임 다시 잰다** — 창 크기·배율·이동이 바뀌어도 띠가 노선을 따라간다. 띠 자체는 * 미터로 정해 두므로 **창 크기와 무관**하다(2026-09-07 사용자 지시 ⑥). */ export function contourBandRect( line: Array<[number, number]>, toScreen: ScreenOf, bandM: number, ): { x: number; y: number; width: number; height: number } | null { if (line.length < 2) return null; let minX = Infinity; let minY = Infinity; let maxX = -Infinity; let maxY = -Infinity; for (const [x, y] of line) { if (x < minX) minX = x; if (x > maxX) maxX = x; if (y < minY) minY = y; if (y > maxY) maxY = y; } // 사업지 좌표(m)에서 띠를 두르고 화면으로 옮긴다 — y 는 화면에서 뒤집힌다. const [left, bottom] = toScreen([minX - bandM, minY - bandM]); const [right, top] = toScreen([maxX + bandM, maxY + bandM]); return { x: Math.min(left, right), y: Math.min(top, bottom), width: Math.abs(right - left), height: Math.abs(bottom - top), }; }