사용자 지적 ③④⑤⑥ 과 추가 지시(되돌리기)를 한 묶음으로 처리. - **③ R 라벨** — 모달 맨 아랫줄이던 곡선 편집칸을 걷고, 고른 꺾임점 **옆에 뜨는 라벨**로 바꿈(`_Label.ts` 신설). 캔버스 밖으로 나가면 반대쪽으로 접어 넣음. 확대·이동·창 크기가 바뀌어도 고른 노드를 따라감. - **④ 노드가 손잡이보다 먼저 잡히게** — 반대였던 탓에 헤어핀처럼 곡선이 몰린 데서는 노드를 아예 못 집었음. 손잡이는 **고른 곡선에만** 그림. 머리말 안내에 조작 여섯을 적음. - **⑤ 줌·팬을 배수유역도와 동일하게**(`_Input.ts` 신설) — 휠은 **당기면 확대**(반대였음), 계수 1.15/0.87, 상한은 화면 폭 16m 기준, 하한 0.5, **팬은 가운데 버튼 전용**. ⚠ 커서 고정 계산이 좌상단 기준이라 확대할수록 지점이 밀리던 것도 중심 기준으로 고침. - **⑥ 등고선을 노선 ±300m 띠로** — 창 크기와 무관한 고정 띠라 창을 늘려도 안 깨짐. 화면 밖 걸러내기는 종전대로 `drawPreparedLayer` 가 함. - **되돌리기·다시하기·초기화**(`_History.ts` 신설) — [확인]이 무거워 되돌릴 길이 없으므로 창 안에서 물릴 수 있게 함. **끌기 한 번이 한 걸음**이고 클릭만으로는 걸음이 안 생김. [초기화]는 **이 창을 연 상태**로 (≠ [예상노선으로]). Ctrl+Z / Ctrl+Y · Ctrl+Shift+Z. - 700줄 제한 — 집기(hit test)를 `_Input.ts` 로 옮겨 본체 695줄. 실화면 검증(5174) — 라벨이 노드 오른쪽 [+18,−38]px 에 뜸 / 휠 당기니 중심에서 373.1 → 428.1px 로 확대 / 왼쪽 끌기로는 지도 안 움직임 / 가운데 버튼 끌기는 [60,40] 그대로 따라옴 / 단추 켜짐이 여섯 단계 모두 맞음. 전체 554 passed · 18 skipped, tsc 통과. 정본 안 건드림. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
184 lines
6.9 KiB
TypeScript
184 lines
6.9 KiB
TypeScript
/* =============================================================================
|
||
* 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.
|
||
*
|
||
* **고른 곡선만** 본다 — 그려 보이는 것만 잡혀야 한다. 잡은 것은 곡선 목록 자리가 아니라
|
||
* **노드 번호**로 돌려준다: 목록은 고칠 때마다 새로 나므로 자리로 들면 끄는 도중 엉뚱한
|
||
* 곡선을 가리킨다. */
|
||
export function handleAtScreen(
|
||
curves: Array<{ node_first: number; start: [number, number]; end: [number, number] }>,
|
||
picked: 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) => {
|
||
if (curve.node_first !== picked) return;
|
||
(["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;
|
||
}
|