Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01D2nUMUMTsqm93PbQtipo6c
438 lines
17 KiB
TypeScript
438 lines
17 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";
|
||
import type { StationLabelBox } from "../B04_PreProcess/B04_PreProcess_UI_MapOverlays";
|
||
import { curveArcVertices, type EditedCurve } from "./B05_Profile_UI_RouteEdit_Curve";
|
||
|
||
/** 배수유역도와 같은 값 — 한 번에 얼마나 확대·축소할지. */
|
||
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;
|
||
/** 돌린 지도 보정 — 화면에서 민 만큼(dx, dy)을 **그림 좌표의 만큼**으로 바꾼다.
|
||
* 안 주면 안 돌린 것으로 본다(배수유역도는 회전이 없다). */
|
||
unrotateDelta?: (dx: number, dy: number) => [number, number];
|
||
/** 마우스 왼쪽 버튼으로도 팬 — 집을 노드가 없는 미리 보기 지도용. 편집 창은 가운데 버튼만. */
|
||
leftPan?: boolean;
|
||
draw: () => void;
|
||
}
|
||
|
||
/** 캔버스에 휠 확대·가운데 버튼 팬을 붙인다. 리스너는 캔버스와 수명이 같다. */
|
||
export function bindRouteEditNavigation(params: RouteEditNavigationParams): void {
|
||
const { canvas, getView, setView, getMeta, draw } = params;
|
||
const unrotateDelta = params.unrotateDelta ?? ((dx: number, dy: number) => [dx, dy]);
|
||
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, cursorY] = unrotateDelta(
|
||
event.clientX - rect.left - rect.width / 2,
|
||
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 &&
|
||
!(params.leftPan && event.button === 0)
|
||
)
|
||
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;
|
||
const [dx, dy] = unrotateDelta(event.clientX - dragStart.x, event.clientY - dragStart.y);
|
||
setView({ ...getView(), offsetX: dragStart.offsetX + dx, offsetY: dragStart.offsetY + dy });
|
||
draw();
|
||
});
|
||
|
||
const stop = (): void => {
|
||
if (!dragStart) return;
|
||
dragStart = null;
|
||
canvas.style.removeProperty("cursor");
|
||
};
|
||
canvas.addEventListener("pointerup", stop);
|
||
canvas.addEventListener("pointercancel", stop);
|
||
}
|
||
|
||
export interface NodeSpliceParams {
|
||
canvas: HTMLCanvasElement;
|
||
/** 돌린 지도 보정 — 화면 px 를 돌리기 전 그림 px 로. */
|
||
unrotate: (px: number, py: number) => [number, number];
|
||
segmentAt: (px: number, py: number) => number;
|
||
nodeAt: (px: number, py: number) => number;
|
||
/** 선(`segment` 번째 구간) 위 그림 px 자리에 노드를 끼운다. */
|
||
insert: (segment: number, px: number, py: number) => void;
|
||
/** `index` 번째 노드를 뺀다. */
|
||
remove: (index: number) => void;
|
||
}
|
||
|
||
/** 선 두 번 클릭 = 노드 추가 · 노드 오른쪽 클릭 = 삭제. 리스너는 캔버스와 수명이 같다. */
|
||
export function bindNodeSplice(params: NodeSpliceParams): void {
|
||
const { canvas } = params;
|
||
const at = (event: MouseEvent): [number, number] => {
|
||
const rect = canvas.getBoundingClientRect();
|
||
return params.unrotate(event.clientX - rect.left, event.clientY - rect.top);
|
||
};
|
||
canvas.addEventListener("dblclick", (event) => {
|
||
const [px, py] = at(event);
|
||
const segment = params.segmentAt(px, py);
|
||
if (segment >= 0) params.insert(segment, px, py);
|
||
});
|
||
canvas.addEventListener("contextmenu", (event) => {
|
||
event.preventDefault();
|
||
const index = params.nodeAt(...at(event));
|
||
if (index >= 0) params.remove(index);
|
||
});
|
||
}
|
||
|
||
/* ── 집기(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;
|
||
}
|
||
|
||
/** 호(곡선 부분)를 눌렀다고 볼 거리(px) — 노선 선 굵기보다 넉넉히. */
|
||
export const ARC_HIT_PX = 8;
|
||
|
||
/** 화면 좌표에 가장 가까운 **호**를 가진 곡선의 노드 번호(`node_first`). 문턱 밖이면 -1.
|
||
*
|
||
* 호는 그려지는 선과 같은 점열(`curveArcVertices`)을 화면으로 옮겨 선분 거리로 잰다.
|
||
* 손잡이(접선점)는 호의 양 끝이라 이 판정에도 걸린다 — 손잡이를 먼저 보는 것은 부르는 쪽 몫. */
|
||
export function arcAtScreen(
|
||
curves: ReadonlyArray<EditedCurve>,
|
||
toScreen: ScreenOf,
|
||
px: number,
|
||
py: number,
|
||
hitPx: number = ARC_HIT_PX,
|
||
): number {
|
||
let best = -1;
|
||
let bestDistance = hitPx;
|
||
for (const curve of curves) {
|
||
const arc = curveArcVertices(curve).map(([x, y]) => toScreen([x, y]));
|
||
for (let index = 0; index < arc.length - 1; index += 1) {
|
||
const [ax, ay] = arc[index];
|
||
const [bx, by] = arc[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 = curve.node_first;
|
||
}
|
||
}
|
||
}
|
||
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;
|
||
}
|
||
|
||
/** 노선 위 한 점 — 어디를 짚었나와 그 자리의 누가거리. */
|
||
export interface RoutePointHit {
|
||
/** 사업지 좌표(m). */
|
||
point: [number, number];
|
||
/** 시점에서 노선을 따라간 거리(m). */
|
||
chainageM: number;
|
||
}
|
||
|
||
/**
|
||
* 노선(그려지는 폴리라인) 위에서 **클릭에 가장 가까운 점**과 그 누가거리. 멀면 null.
|
||
*
|
||
* 직선·곡선을 가리지 않는다(계획서 0-9 ⑤) — 원호도 이미 정점으로 펴져 있어 같은 선분 훑기로
|
||
* 잡힌다. 누가거리는 선분 길이를 누적해 구하므로 노선 길이 표기와 같은 값을 본다.
|
||
*/
|
||
export function routePointAtScreen(
|
||
line: Array<[number, number]>,
|
||
toScreen: ScreenOf,
|
||
px: number,
|
||
py: number,
|
||
maxPx: number,
|
||
): RoutePointHit | null {
|
||
let best: RoutePointHit | null = null;
|
||
let bestDistance = maxPx;
|
||
let travelled = 0;
|
||
for (let index = 0; index < line.length - 1; index += 1) {
|
||
const from = line[index];
|
||
const to = line[index + 1];
|
||
const segmentM = Math.hypot(to[0] - from[0], to[1] - from[1]);
|
||
const [ax, ay] = toScreen(from);
|
||
const [bx, by] = toScreen(to);
|
||
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 = {
|
||
point: [from[0] + (to[0] - from[0]) * t, from[1] + (to[1] - from[1]) * t],
|
||
chainageM: travelled + segmentM * t,
|
||
};
|
||
}
|
||
travelled += segmentM;
|
||
}
|
||
return best;
|
||
}
|
||
|
||
/**
|
||
* 클릭에 가장 가까운 **규칙 측점**의 누가거리(m). 그만큼 안에 없으면 null(계획서 0-9 ⑧).
|
||
*
|
||
* 눈금을 그리는 `drawStationTicks` 와 **같은 자리**를 짚는다 — 선분 길이를 누적해 측점 간격
|
||
* 마다 한 점씩 보간한다. 눈금이 보이는 자리를 눌렀는데 안 잡히면 안 되기 때문이다.
|
||
*/
|
||
export function stationAtScreen(
|
||
line: Array<[number, number]>,
|
||
toScreen: ScreenOf,
|
||
intervalM: number,
|
||
px: number,
|
||
py: number,
|
||
maxPx: number,
|
||
labels: ReadonlyArray<StationLabelBox> = [],
|
||
): number | null {
|
||
// 라벨이 그려진 측점만 눈금도 보인다(`drawStationTicks` 가 성기게 솎는다) — 안 보이는 눈금이
|
||
// 호·등고선 누름을 가로채지 않게 한다. 라벨 정보가 없으면 전부 본다.
|
||
const shown = labels.length
|
||
? new Set(labels.map((box) => Math.round(box.chainage / intervalM)))
|
||
: null;
|
||
const tick = stationTickAtScreen(line, toScreen, intervalM, px, py, maxPx, shown);
|
||
// 눈금과 라벨이 겹치면 눈금이 먼저다 — 라벨은 눈금을 못 잡았을 때만 본다.
|
||
return tick ?? stationLabelAtScreen(labels, px, py);
|
||
}
|
||
|
||
/** 그려 둔 측점 라벨 글자 상자 — 그릴 때마다 `Render` 가 새로 적는다. 창(캔버스)마다 따로. */
|
||
const labelBoxes = new WeakMap<HTMLCanvasElement, ReadonlyArray<StationLabelBox>>();
|
||
export const setStationLabelBoxes = (
|
||
canvas: HTMLCanvasElement,
|
||
boxes: ReadonlyArray<StationLabelBox>,
|
||
): void => void labelBoxes.set(canvas, boxes);
|
||
export const stationLabelBoxes = (canvas: HTMLCanvasElement): ReadonlyArray<StationLabelBox> =>
|
||
labelBoxes.get(canvas) ?? [];
|
||
|
||
/** 라벨 글자 상자 안을 눌렀나 — 상자는 가운데를 축으로 `uprightRad` 만큼 돌아 있어, 눌린 자리를
|
||
* 거꾸로 돌려 세운 뒤 가로 반폭 · 세로 반폭(8) 안인지 본다. 여럿이면 가운데가 가까운 것. */
|
||
export function stationLabelAtScreen(
|
||
labels: ReadonlyArray<StationLabelBox>,
|
||
px: number,
|
||
py: number,
|
||
): number | null {
|
||
let best: number | null = null;
|
||
let bestDistance = Infinity;
|
||
for (const box of labels) {
|
||
const dx = px - box.x;
|
||
const dy = py - box.y;
|
||
const cos = Math.cos(-box.uprightRad);
|
||
const sin = Math.sin(-box.uprightRad);
|
||
if (Math.abs(dx * cos - dy * sin) > box.half || Math.abs(dx * sin + dy * cos) > 8) continue;
|
||
const distance = Math.hypot(dx, dy);
|
||
if (distance < bestDistance) {
|
||
bestDistance = distance;
|
||
best = box.chainage;
|
||
}
|
||
}
|
||
return best;
|
||
}
|
||
|
||
function stationTickAtScreen(
|
||
line: Array<[number, number]>,
|
||
toScreen: ScreenOf,
|
||
intervalM: number,
|
||
px: number,
|
||
py: number,
|
||
maxPx: number,
|
||
shown: ReadonlySet<number> | null,
|
||
): number | null {
|
||
if (line.length < 2 || !(intervalM > 0)) return null;
|
||
const cumulative: number[] = [0];
|
||
for (let index = 1; index < line.length; index += 1) {
|
||
cumulative.push(
|
||
cumulative[index - 1] +
|
||
Math.hypot(line[index][0] - line[index - 1][0], line[index][1] - line[index - 1][1]),
|
||
);
|
||
}
|
||
const total = cumulative[cumulative.length - 1];
|
||
let best: number | null = null;
|
||
let bestDistance = maxPx;
|
||
let cursor = 1;
|
||
for (let chainage = 0; chainage <= total; chainage += intervalM) {
|
||
if (shown && !shown.has(Math.round(chainage / intervalM))) continue;
|
||
while (cursor < cumulative.length - 1 && cumulative[cursor] < chainage) cursor += 1;
|
||
const back = line[cursor - 1];
|
||
const front = line[cursor];
|
||
const segment = cumulative[cursor] - cumulative[cursor - 1] || 1;
|
||
const ratio = Math.min(1, Math.max(0, (chainage - cumulative[cursor - 1]) / segment));
|
||
const [x, y] = toScreen([
|
||
back[0] + (front[0] - back[0]) * ratio,
|
||
back[1] + (front[1] - back[1]) * ratio,
|
||
]);
|
||
const distance = Math.hypot(x - px, y - py);
|
||
if (distance < bestDistance) {
|
||
bestDistance = distance;
|
||
best = chainage;
|
||
}
|
||
}
|
||
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),
|
||
};
|
||
}
|