Files
Aislo/B05_Profile/B05_Profile_UI_RouteEdit_Input.ts
T
eomsangdonandClaude Opus 5 332948694e feat(B05): 측점 눈금을 누르면 그 측점 횡단 미리보기 창
- 원지반 횡단선·기본 계획 횡단선·성토사면 길이 셋만 보임, 구조물은 안 그림 (계획서 0-9 ⑧)
- 서버 `POST /route/cross-preview` 가 `generate_sections`+`compute_cross_design` 정본을 재사용
- 계획고는 편집 중에 없어 그 측점 지반고를 그대로 놓음(지반 추종) — 창에 그렇게 적음
- 성토사면 길이는 B06 화면이 쓰는 `fillSlopeLengths` 를 그대로 부름
- 700줄 규정에 맞춰 곡선 패널 배선을 `_CurveBar.ts` 로 분리

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_012jsXWphgRUHAG2mFupSKPX
2026-09-12 14:39:35 +09:00

306 lines
11 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
/* =============================================================================
* 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*(1scale)` 을 품고
* 있어 좌상단 기준으로 셈하면 확대할수록 커서 아래 지점이 밀린다(옛 모달의 버그).
* ========================================================================== */
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;
}
/** 노선 위 한 점 — 어디를 짚었나와 그 자리의 누가거리. */
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,
): 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) {
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),
};
}