diff --git a/B05_Profile/B05_Profile_UI_RouteEdit.ts b/B05_Profile/B05_Profile_UI_RouteEdit.ts index 1d2b41fb..c54db379 100644 --- a/B05_Profile/B05_Profile_UI_RouteEdit.ts +++ b/B05_Profile/B05_Profile_UI_RouteEdit.ts @@ -30,14 +30,33 @@ import { fetchDrainageLayers } from "./B05_Profile_UI_Drainage_Parts"; import { fetchRoutePlan, replanRoute, resetRoutePlan } from "./B05_Profile_Api_Replan"; import type { RoutePlanCurve } from "./B05_Profile_Api_Replan"; import { buildEditedPolyline, dragHandleTo as curveDragTo } from "./B05_Profile_UI_RouteEdit_Curve"; +import { + bindRouteEditNavigation, + handleAtScreen, + nodeAtScreen, + segmentAtScreen, +} from "./B05_Profile_UI_RouteEdit_Input"; +import { createCurveLabel } from "./B05_Profile_UI_RouteEdit_Label"; +import { + bindHistoryControls, + createRouteEditHistory, + type RouteEditHistory, + type RouteEditSnapshot, +} from "./B05_Profile_UI_RouteEdit_History"; import "./B05_Profile_UI_Style_RouteEdit.css"; /** 노드를 잡았다고 볼 거리(px). 손가락·마우스 모두 무리 없는 크기. */ const NODE_HIT_PX = 9; /** 노드 반지름(px). */ const NODE_R = 4; -/** 끌기로 볼 최소 이동(px) — 이보다 작으면 클릭으로 본다. */ -const DRAG_THRESHOLD_PX = 3; +/** 선을 두 번 눌러 노드를 끼울 때, 선에서 이만큼(px) 안쪽이면 그 선으로 본다. */ +const SEGMENT_HIT_PX = 12; +/** 등고선을 보일 **노선 둘레 띠**(m) — 사용자 지시 ⑥(2026-09-07). + * + * 노선에서 이만큼 밖의 등고선은 안 그린다. **창 크기와 무관한 고정 띠**라 창을 늘리거나 + * 줄여도 띠가 흔들리지 않는다(사용자가 「다이나믹 창이라 조심」이라 한 자리). 화면 밖을 + * 걸러내는 일은 `drawPreparedLayer` 가 이미 하므로 여기서는 띠만 덧씌운다. */ +const CONTOUR_BAND_M = 300; /** 곡선 시작·끝점 손잡이 크기(px) — 노드 동그라미와 구별되게 **속 빈 네모**로 그린다. * 처음엔 3.5px 였는데 선과 색이 같아 눈에도 안 띄고 집기도 어려웠다(2026-09-07 실화면). */ const CURVE_HANDLE_PX = 5; @@ -56,28 +75,24 @@ export async function openRouteEditModal(
계획노선 편집 - 노드를 끌어 옮기고, 선을 두 번 누르면 노드가 생깁니다. 노드 오른쪽 클릭은 삭제. + 노드 끌기 = 옮기기 · 노드 클릭 = R 라벨 · 선 두 번 클릭 = 노드 추가 · + 노드 오른쪽 클릭 = 삭제 · 가운데(휠) 버튼 끌기 = 지도 이동 · 휠 = 확대
-
노선을 읽는 중… 예상노선(원본) 계획노선 + + + @@ -110,6 +125,8 @@ export async function openRouteEditModal( let curveRadius: Array = []; /** 지금 고른 꺾임점 — 곡선 편집줄이 이 자리를 만진다. 없으면 -1. */ let picked = -1; + /** 되돌리기 사진첩 — 노선을 읽은 뒤에 선다(그전에는 되돌릴 것이 없다). */ + let history: RouteEditHistory | null = null; let meta: VWorldMeta | null = null; let sheets: PreparedLayer[] = []; let view: ViewState = { @@ -125,6 +142,7 @@ export async function openRouteEditModal( const close = (): void => { closed = true; window.removeEventListener("resize", resize); + historyControls.dispose(); // 단축키는 창(window)에 달려 있어 안 떼면 닫힌 뒤에도 산다. overlay.remove(); }; overlay.querySelector(".b05-routeedit__close")!.addEventListener("click", close); @@ -181,6 +199,34 @@ export async function openRouteEditModal( context.restore(); } + /** 등고선을 보일 화면 사각형 — 노선 경계에 `CONTOUR_BAND_M` 를 두른 것. + * + * **매 프레임 다시 잰다** — 창 크기·배율·이동이 바뀌어도 띠가 노선을 따라간다. 노선이 + * 아직 없으면 null(전체를 그린다). */ + function contourBandRect(): { x: number; y: number; width: number; height: number } | null { + const line = plannedLine.length ? plannedLine : planned; + if (!meta || 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 - CONTOUR_BAND_M, minY - CONTOUR_BAND_M]); + const [right, top] = toScreen([maxX + CONTOUR_BAND_M, maxY + CONTOUR_BAND_M]); + return { + x: Math.min(left, right), + y: Math.min(top, bottom), + width: Math.abs(right - left), + height: Math.abs(bottom - top), + }; + } + function draw(): void { if (closed) return; const style = getComputedStyle(document.documentElement); @@ -189,6 +235,14 @@ export async function openRouteEditModal( context.fillRect(0, 0, view.width, view.height); context.save(); + // 등고선은 **노선 둘레 300m 안**에서만 그린다 — 노선과 상관없는 산줄기까지 다 그리면 + // 화면이 등고선으로 덮여 노선이 안 보인다(2026-09-07 사용자 지시 ⑥). + const band = contourBandRect(); + if (band) { + context.beginPath(); + context.rect(band.x, band.y, band.width, band.height); + context.clip(); + } context.strokeStyle = style.getPropertyValue("--map-sheet-contour") || "#a5b4fc"; context.lineWidth = 0.8; for (const layer of sheets) drawPreparedLayer(context, layer, view, "dot"); @@ -239,6 +293,9 @@ export async function openRouteEditModal( // **속을 비우고 테두리를 굵게** 그린다 — 선·노드와 색이 같으면 눈에도 안 띄고 집기도 어렵다. context.lineWidth = 2; curveInfo.forEach((curve) => { + // **고른 곡선에만** 손잡이를 낸다(2026-09-07 사용자 지적 ④) — 전부 내놓으면 헤어핀에서 + // 노드 위를 덮어 노드를 못 집는다. 먼저 노드를 눌러 고르고, 그 다음 손잡이를 끈다. + if (curve.node_first !== picked) return; const on = curveOn[curve.node_first] !== false; if (!on) return; // 곡선을 지운 자리에는 손잡이도 없다. [curve.start, curve.end].forEach((point) => { @@ -257,28 +314,17 @@ export async function openRouteEditModal( }); }); context.restore(); + // 라벨은 **그린 뒤** 자리를 맞춘다 — 확대·이동·창 크기가 바뀌어도 고른 노드에 붙어 있게. + syncCurveBar(); } - /** 화면 좌표에 가장 가까운 **곡선 손잡이**(시작·끝점). 없으면 null. - * - * 잡은 것을 **노드 번호**로 기억한다 — 곡선 목록은 고칠 때마다 다시 만들어지므로 목록 - * 자리(index)로 들고 있으면 끄는 도중 엉뚱한 곡선을 가리키게 된다. */ - function handleAt(px: number, py: number): { node: number; end: "start" | "end" } | null { - let best: { node: number; end: "start" | "end" } | null = null; - let bestDistance = NODE_HIT_PX + 2; - curveInfo.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; - } + /** 잡기 — 셈은 `_RouteEdit_Input` 몫이고, 여기서는 지금 상태를 건네준다. */ + const handleAt = (px: number, py: number): { node: number; end: "start" | "end" } | null => + handleAtScreen(curveInfo, picked, toScreen, px, py, NODE_HIT_PX + 2); + const nodeAt = (px: number, py: number): number => + nodeAtScreen(planned, toScreen, px, py, NODE_HIT_PX); + const segmentAt = (px: number, py: number): number => + segmentAtScreen(planned, toScreen, px, py, SEGMENT_HIT_PX); /** 끈 접선점으로 새 교각점·새 반지름을 구한다 — 셈은 `_RouteEdit_Curve` 몫. */ function dragHandleTo( @@ -294,41 +340,6 @@ export async function openRouteEditModal( return curveDragTo(before, [curve.apex[0], curve.apex[1]], after, which, to); } - /** 화면 좌표에 가장 가까운 노드. 문턱 밖이면 -1. */ - function nodeAt(px: number, py: number): number { - let best = -1; - let bestDistance = NODE_HIT_PX; - planned.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; - } - - /** 두 노드 사이 선분 중 클릭에 가장 가까운 것 — 새 노드를 끼울 자리. */ - function segmentAt(px: number, py: number): number { - let best = -1; - let bestDistance = 12; - for (let index = 0; index < planned.length - 1; index += 1) { - const [ax, ay] = toScreen(planned[index]); - const [bx, by] = toScreen(planned[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; - } - /** 노드를 고쳤다 — **곡선을 그 자리에서 다시 그린다**(2026-09-07 사용자 지적 ①②). * * 예전에는 그려 둔 선과 손잡이를 통째로 비웠다. 그러면 노드 하나만 건드려도 **곡선이 전부 @@ -363,100 +374,101 @@ export async function openRouteEditModal( ); } - // ── 곡선 편집줄 — 고른 자리의 R 을 바꾸고, 곡선을 지우고 넣는다(2026-09-07 사용자 지시) ── - const curveBar = overlay.querySelector(".b05-routeedit__curve")!; - const curveLabel = curveBar.querySelector(".b05-routeedit__curve-label")!; - const curveRadiusInput = curveBar.querySelector( - ".b05-routeedit__curve-radius", - )!; - const curveInfoText = curveBar.querySelector(".b05-routeedit__curve-info")!; - const curveOffBtn = curveBar.querySelector('[data-act="curve-off"]')!; - const curveOnBtn = curveBar.querySelector('[data-act="curve-on"]')!; - const curveAutoBtn = curveBar.querySelector('[data-act="curve-auto"]')!; + // ── R 라벨 — 고른 꺾임점 **옆에** 뜬다(2026-09-07 사용자 지시 ③). 그리기는 `_Label` 몫 ── + const curveLabelBox = createCurveLabel(canvas.parentElement!, { + onRadius: (value) => { + if (picked < 0) return; + curveRadius[picked] = value; + // 반지름만 바꾼 것이라 노드 자리는 그대로지만, 그려진 선은 낡았다. + applyEdit(value === null ? "반지름을 자동으로 되돌렸습니다." : "반지름을 바꿨습니다."); + }, + onCurveOn: (on) => { + if (picked < 0) return; + curveOn[picked] = on; + applyEdit(on ? "곡선을 넣었습니다." : "곡선을 지웠습니다."); + }, + onAuto: () => { + if (picked < 0) return; + curveRadius[picked] = null; // 서버가 예정노선에 맞춰 다시 고른다. + applyEdit("반지름을 자동으로 되돌렸습니다."); + }, + }); - /** 고른 자리에 맞춰 편집줄을 다시 그린다. 끝점은 곡선이 없으므로 줄을 숨긴다. */ + /** 고른 자리에 맞춰 라벨을 옮겨 그린다. 끝점은 곡선이 없으므로 라벨을 숨긴다. */ function syncCurveBar(): void { - const editable = picked > 0 && picked < planned.length - 1; - curveBar.hidden = !editable; - if (!editable) return; - const on = curveOn[picked] !== false; - curveLabel.textContent = `${picked + 1}번째 꺾임점`; - curveOffBtn.hidden = !on; - curveOnBtn.hidden = on; - curveRadiusInput.disabled = !on; - curveAutoBtn.disabled = !on || curveRadius[picked] === null; + if (!(picked > 0 && picked < planned.length - 1)) { + curveLabelBox.hide(); + return; + } const forced = curveRadius[picked]; - const shown = forced ?? curveInfo.find((c) => c.node_first === picked)?.radius_m ?? null; - curveRadiusInput.value = shown === null ? "" : String(Math.round(shown * 10) / 10); - const inner = nodeInfo[picked]?.inner_angle_deg; - curveInfoText.textContent = on - ? `${forced === null ? "자동" : "값 지정"}${inner ? ` · 내각 ${Math.round(inner)}°` : ""}` + - ` · 법정 하한 ${minRadiusM}m` - : "곡선 없음 — 직선이 그대로 꺾입니다"; + curveLabelBox.show({ + seat: picked, + at: toScreen(planned[picked]), + canvas: { width: view.width, height: view.height }, + curveOn: curveOn[picked] !== false, + radiusShown: + forced ?? curveInfo.find((entry) => entry.node_first === picked)?.radius_m ?? null, + forced: forced !== null, + innerAngleDeg: nodeInfo[picked]?.inner_angle_deg ?? null, + minRadiusM, + }); } - curveRadiusInput.addEventListener("change", () => { - if (picked < 0) return; - const value = Number(curveRadiusInput.value); - curveRadius[picked] = Number.isFinite(value) && value > 0 ? value : null; - // 반지름만 바꾼 것이라 노드 자리는 그대로지만, 그려진 선은 낡았다. + /** 한 번의 편집을 마무리한다 — 다시 그리고, 라벨·상태줄을 맞추고, 되돌리기에 쌓는다. */ + function applyEdit(message: string, record = true): void { markEdited(); syncCurveBar(); - status.textContent = `노드 ${planned.length}개 — 반지름을 바꿨습니다. ${curveHint()}`; + status.textContent = `노드 ${planned.length}개 — ${message} ${curveHint()}`; draw(); + if (record) history?.commit(snapshotNow()); + historyControls.sync(); + } + + /** 지금 편집값을 사진 한 벌로 담는다 — 되돌리기가 쌓아 두는 것. */ + function snapshotNow(): RouteEditSnapshot { + return { planned, curveOn, curveRadius, picked }; + } + + const historyControls = bindHistoryControls({ + overlay, + getHistory: () => history, + restore: (snapshot, message) => { + planned = snapshot.planned; + curveOn = snapshot.curveOn; + curveRadius = snapshot.curveRadius; + picked = snapshot.picked; + applyEdit(message, false); // 되살리는 것은 새 걸음이 아니다. + }, }); - curveOffBtn.addEventListener("click", () => { - if (picked < 0) return; - curveOn[picked] = false; - markEdited(); - syncCurveBar(); - status.textContent = `노드 ${planned.length}개 — 곡선을 지웠습니다. ${curveHint()}`; - draw(); - }); - - curveOnBtn.addEventListener("click", () => { - if (picked < 0) return; - curveOn[picked] = true; - markEdited(); - syncCurveBar(); - status.textContent = `노드 ${planned.length}개 — 곡선을 넣었습니다. ${curveHint()}`; - draw(); - }); - - curveAutoBtn.addEventListener("click", () => { - if (picked < 0) return; - curveRadius[picked] = null; // 서버가 예정노선에 맞춰 다시 고른다. - markEdited(); - syncCurveBar(); - status.textContent = `노드 ${planned.length}개 — 반지름을 자동으로 되돌렸습니다. ${curveHint()}`; - draw(); - }); - - // ── 조작 — 노드 끌기 / 배경 끌기(팬) / 휠 확대 / 두 번 클릭 삽입 / 오른쪽 클릭 삭제 ── + // ── 조작 — 노드 끌기 / 두 번 클릭 삽입 / 오른쪽 클릭 삭제 ── + // 지도 이동·확대는 `bindRouteEditNavigation`(가운데 버튼 팬 · 휠) 몫이다. let dragNode = -1; - /** 끌고 있는 곡선 손잡이(시작·끝점). 노드 끌기보다 우선한다. */ + /** 끌고 있는 곡선 손잡이(시작·끝점). **고른 곡선에만** 있다. */ let dragHandle: { node: number; end: "start" | "end" } | null = null; - let panFrom: { x: number; y: number; offsetX: number; offsetY: number } | null = null; + /** 이번 끌기에서 **실제로 움직였나** — 그냥 눌러 고르기만 한 것은 되돌릴 걸음이 아니다 + * (2026-09-07 실화면: 노드를 클릭만 해도 [되돌리기]가 켜졌다). */ + let dragMoved = false; canvas.addEventListener("pointerdown", (event) => { if (event.button !== 0) return; const rect = canvas.getBoundingClientRect(); const px = event.clientX - rect.left; const py = event.clientY - rect.top; - // 곡선 손잡이가 노드보다 먼저다 — 겹치면 손잡이를 잡는다(더 세밀한 조작). - dragHandle = handleAt(px, py); - dragNode = dragHandle ? -1 : nodeAt(px, py); - if (dragHandle) { + // **노드가 손잡이보다 먼저다**(2026-09-07 사용자 지적 ④). 반대로 두었더니 헤어핀처럼 + // 곡선이 몰린 데서는 손잡이가 늘 먼저 잡혀 **노드를 아예 못 집었다**(실화면에서 격자로 + // 훑어 보니 잡히는 것이 전부 손잡이였음). 손잡이는 고른 곡선에만 나오므로 겹침도 적다. + dragNode = nodeAt(px, py); + dragHandle = dragNode >= 0 ? null : handleAt(px, py); + dragMoved = false; + if (dragNode >= 0) { + picked = dragNode; // 누른 자리를 고른다 — R 라벨이 그 곡선을 만진다. + syncCurveBar(); + draw(); + } else if (dragHandle) { picked = dragHandle.node; syncCurveBar(); draw(); - } else if (dragNode >= 0) { - picked = dragNode; // 누른 자리를 고른다 — 편집줄이 그 곡선을 만진다. - syncCurveBar(); - draw(); - } else { - panFrom = { x: px, y: py, offsetX: view.offsetX, offsetY: view.offsetY }; } canvas.setPointerCapture(event.pointerId); }); @@ -476,6 +488,7 @@ export async function openRouteEditModal( picked = node; // 손잡이 자리는 다시 셈한 곡선에서 나온다 — 접선 자리가 모자라 R 이 눌리면 손이 // 끄는 자리보다 덜 따라오고, 그 눌림이 그 자리에서 눈에 보인다. + dragMoved = true; markEdited(); syncCurveBar(); status.textContent = `노드 ${planned.length}개 — 곡선을 잡는 중. ${curveHint()}`; @@ -484,6 +497,7 @@ export async function openRouteEditModal( return; } if (dragNode >= 0) { + dragMoved = true; planned[dragNode] = toMetric(px, py); markEdited(); // 곡선을 그 자리에서 다시 그린다 — 나머지 곡선은 그대로 남는다. // 끄는 동안에도 상태줄이 살아 있어야 한다 — 예전에는 여기서 아무 말이 없어 @@ -492,24 +506,20 @@ export async function openRouteEditModal( draw(); return; } - if (panFrom) { - if (Math.hypot(px - panFrom.x, py - panFrom.y) < DRAG_THRESHOLD_PX) return; - view = { - ...view, - offsetX: panFrom.offsetX + (px - panFrom.x), - offsetY: panFrom.offsetY + (py - panFrom.y), - }; - draw(); - return; - } - canvas.style.cursor = handleAt(px, py) || nodeAt(px, py) >= 0 ? "grab" : "default"; + canvas.style.cursor = nodeAt(px, py) >= 0 || handleAt(px, py) ? "grab" : "default"; }); const endDrag = (event: PointerEvent): void => { if (canvas.hasPointerCapture(event.pointerId)) canvas.releasePointerCapture(event.pointerId); + // 끌기 **한 번**이 되돌리기 한 걸음이다 — 프레임마다 쌓으면 한 번 물리는 데 수십 번 + // 눌러야 한다. 놓는 순간에만 쌓는다. + if (dragMoved) { + history?.commit(snapshotNow()); + historyControls.sync(); + } dragNode = -1; dragHandle = null; - panFrom = null; + dragMoved = false; }; canvas.addEventListener("pointerup", endDrag); canvas.addEventListener("pointercancel", endDrag); @@ -525,10 +535,7 @@ export async function openRouteEditModal( curveOn.splice(segment + 1, 0, true); curveRadius.splice(segment + 1, 0, null); picked = segment + 1; - markEdited(); - syncCurveBar(); - status.textContent = `노드 ${planned.length}개 — 새 노드를 넣었습니다(직선 추가). ${curveHint()}`; - draw(); + applyEdit("새 노드를 넣었습니다(직선 추가)."); }); canvas.addEventListener("contextmenu", (event) => { @@ -544,33 +551,19 @@ export async function openRouteEditModal( curveOn.splice(index, 1); curveRadius.splice(index, 1); picked = -1; - markEdited(); - syncCurveBar(); - status.textContent = `노드 ${planned.length}개 — 노드를 지웠습니다(직선 삭제). ${curveHint()}`; - draw(); + applyEdit("노드를 지웠습니다(직선 삭제)."); }); - canvas.addEventListener( - "wheel", - (event) => { - event.preventDefault(); - const rect = canvas.getBoundingClientRect(); - const px = event.clientX - rect.left; - const py = event.clientY - rect.top; - const factor = event.deltaY < 0 ? 1.2 : 1 / 1.2; - const nextScale = Math.max(1, Math.min(2000, view.scale * factor)); - const ratio = nextScale / view.scale; - // 커서 아래 지점이 제자리에 남도록 이동량을 함께 고친다. - view = { - ...view, - scale: nextScale, - offsetX: px - (px - view.offsetX) * ratio, - offsetY: py - (py - view.offsetY) * ratio, - }; - draw(); + // 확대·이동은 배수유역도와 같은 동작으로 — 휠은 당기면 확대, 팬은 가운데 버튼 전용. + bindRouteEditNavigation({ + canvas, + getView: () => view, + setView: (next) => { + view = next; }, - { passive: false }, - ); + getMeta: () => meta, + draw, + }); async function runHeavy(label: string, task: () => Promise): Promise { busy.hidden = false; @@ -667,6 +660,9 @@ export async function openRouteEditModal( if (curve) curveInfo.push({ ...curve, node_first: seat, node_last: seat }); }); picked = -1; + // 여기가 [초기화]가 돌아갈 자리다 — 창을 연 그대로. + history = createRouteEditHistory(snapshotNow()); + historyControls.sync(); syncCurveBar(); if (!planned.length) planned = plannedLine.map((vertex) => [vertex[0], vertex[1]]); meta = drainage.meta; diff --git a/B05_Profile/B05_Profile_UI_RouteEdit_History.ts b/B05_Profile/B05_Profile_UI_RouteEdit_History.ts new file mode 100644 index 00000000..a460c458 --- /dev/null +++ b/B05_Profile/B05_Profile_UI_RouteEdit_History.ts @@ -0,0 +1,161 @@ +/* ============================================================================= + * B05_Profile_UI_RouteEdit_History.ts + * 노선 편집의 **되돌리기·다시하기·초기화** (2026-09-07 사용자 지시). + * + * 왜 필요한가 — [확인]은 배수유역부터 다시 도는 무거운 작업이라 되돌릴 길이 없다. 그러니 + * **창 안에서** 실수를 물릴 수 있어야 한다. 여기서 말하는 [초기화]는 **이 창을 연 상태**로 + * 돌아가는 것이지, 예상노선으로 되돌리는 것(`[예상노선으로]`, 서버 재계산)이 아니다. + * + * 값을 통째로 사진처럼 담는다(델타 아님) — 노드·곡선 켬끔·반지름이 서로 엮여 있어 델타로 + * 쪼개면 되돌릴 때 어긋나기 쉽다. 노선 하나가 노드 수십 개라 사진 몇 벌은 가볍다. + * ========================================================================== */ + +export type Vertex = [number, number]; + +/** 되돌릴 수 있는 편집 상태 한 벌. */ +export interface RouteEditSnapshot { + planned: Vertex[]; + curveOn: boolean[]; + curveRadius: Array; + picked: number; +} + +/** 쌓아 둘 사진 수 상한 — 넘으면 오래된 것부터 버린다. */ +const MAX_STEPS = 100; + +export interface RouteEditHistory { + /** 편집 한 번이 끝났다 — 지금 상태를 사진으로 쌓는다(다시하기 갈래는 버린다). */ + commit: (snapshot: RouteEditSnapshot) => void; + /** 한 걸음 뒤로. 되돌릴 것이 없으면 null. */ + undo: () => RouteEditSnapshot | null; + /** 한 걸음 앞으로. 없으면 null. */ + redo: () => RouteEditSnapshot | null; + /** 창을 연 상태로. 이미 그 상태면 null. */ + reset: () => RouteEditSnapshot | null; + canUndo: () => boolean; + canRedo: () => boolean; + /** 창을 연 뒤로 고친 것이 있나 — [초기화]를 켤지 정한다. */ + isDirty: () => boolean; +} + +/** 사진을 깊이 복사한다 — 배열을 그대로 담으면 뒤이은 편집이 과거까지 바꾼다. */ +function clone(snapshot: RouteEditSnapshot): RouteEditSnapshot { + return { + planned: snapshot.planned.map(([x, y]): Vertex => [x, y]), + curveOn: [...snapshot.curveOn], + curveRadius: [...snapshot.curveRadius], + picked: snapshot.picked, + }; +} + +/** 두 사진이 **같은 노선**인가 — [초기화]를 켤지 정할 때 쓴다. 고른 자리는 안 본다. */ +function sameRoute(a: RouteEditSnapshot, b: RouteEditSnapshot): boolean { + if (a.planned.length !== b.planned.length) return false; + for (let index = 0; index < a.planned.length; index += 1) { + if (a.planned[index][0] !== b.planned[index][0]) return false; + if (a.planned[index][1] !== b.planned[index][1]) return false; + if (a.curveOn[index] !== b.curveOn[index]) return false; + if (a.curveRadius[index] !== b.curveRadius[index]) return false; + } + return true; +} + +/** 첫 사진(창을 연 상태)으로 이력을 연다. */ +export function createRouteEditHistory(initial: RouteEditSnapshot): RouteEditHistory { + const steps: RouteEditSnapshot[] = [clone(initial)]; + let at = 0; + + return { + commit(snapshot) { + // 되돌린 뒤 새로 고치면 앞쪽 갈래는 버린다 — 흔한 되돌리기 규칙 그대로. + steps.length = at + 1; + steps.push(clone(snapshot)); + if (steps.length > MAX_STEPS) steps.shift(); + at = steps.length - 1; + }, + undo() { + if (at <= 0) return null; + at -= 1; + return clone(steps[at]); + }, + redo() { + if (at >= steps.length - 1) return null; + at += 1; + return clone(steps[at]); + }, + reset() { + // 이미 연 상태 그대로면 할 일이 없다 — 눌러도 걸음만 늘어난다. + if (sameRoute(steps[at], steps[0])) return null; + // 초기화도 **되돌릴 수 있어야** 한다 — 첫 사진을 새 걸음으로 쌓는다. + steps.length = at + 1; + steps.push(clone(steps[0])); + at = steps.length - 1; + return clone(steps[at]); + }, + canUndo: () => at > 0, + canRedo: () => at < steps.length - 1, + isDirty: () => !sameRoute(steps[at], steps[0]), + }; +} + +export interface HistoryControlsParams { + /** 단추가 들어 있는 칸 — `[data-act]` 로 찾는다. */ + overlay: HTMLElement; + /** 아직 노선을 못 읽었으면 null 이다(단추는 꺼진 채로 둔다). */ + getHistory: () => RouteEditHistory | null; + /** 사진 한 벌을 화면에 되살린다. */ + restore: (snapshot: RouteEditSnapshot, message: string) => void; +} + +/** [초기화]·[되돌리기]·[다시하기] 단추와 단축키(Ctrl+Z / Ctrl+Y / Ctrl+Shift+Z)를 붙인다. + * + * 돌려주는 `sync` 를 편집이 끝날 때마다 부르면 단추 켜짐이 맞춰진다. `dispose` 는 창을 + * 닫을 때 부른다 — 단축키를 창(window)에 달았기 때문에 안 떼면 닫힌 뒤에도 살아 있다. */ +export function bindHistoryControls(params: HistoryControlsParams): { + sync: () => void; + dispose: () => void; +} { + const { overlay, getHistory, restore } = params; + const undoBtn = overlay.querySelector('[data-act="undo"]')!; + const redoBtn = overlay.querySelector('[data-act="redo"]')!; + const resetBtn = overlay.querySelector('[data-act="history-reset"]')!; + + const sync = (): void => { + const history = getHistory(); + undoBtn.disabled = !history?.canUndo(); + redoBtn.disabled = !history?.canRedo(); + resetBtn.disabled = !history?.isDirty(); + }; + + const step = (which: "undo" | "redo" | "reset"): void => { + const history = getHistory(); + if (!history) return; + const snapshot = history[which](); + if (!snapshot) return; + restore( + snapshot, + which === "undo" + ? "되돌렸습니다." + : which === "redo" + ? "다시 했습니다." + : "창을 연 상태로 돌렸습니다.", + ); + sync(); + }; + + undoBtn.addEventListener("click", () => step("undo")); + redoBtn.addEventListener("click", () => step("redo")); + resetBtn.addEventListener("click", () => step("reset")); + + const onKey = (event: KeyboardEvent): void => { + if (!(event.ctrlKey || event.metaKey)) return; + const key = event.key.toLowerCase(); + if (key === "z" && !event.shiftKey) step("undo"); + else if (key === "y" || (key === "z" && event.shiftKey)) step("redo"); + else return; + event.preventDefault(); + }; + window.addEventListener("keydown", onKey); + + return { sync, dispose: () => window.removeEventListener("keydown", onKey) }; +} diff --git a/B05_Profile/B05_Profile_UI_RouteEdit_Input.ts b/B05_Profile/B05_Profile_UI_RouteEdit_Input.ts new file mode 100644 index 00000000..a73dd52e --- /dev/null +++ b/B05_Profile/B05_Profile_UI_RouteEdit_Input.ts @@ -0,0 +1,183 @@ +/* ============================================================================= + * 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; +} diff --git a/B05_Profile/B05_Profile_UI_RouteEdit_Label.ts b/B05_Profile/B05_Profile_UI_RouteEdit_Label.ts new file mode 100644 index 00000000..f6538512 --- /dev/null +++ b/B05_Profile/B05_Profile_UI_RouteEdit_Label.ts @@ -0,0 +1,120 @@ +/* ============================================================================= + * B05_Profile_UI_RouteEdit_Label.ts + * 고른 꺾임점 **옆에 뜨는 R 라벨** — 반지름을 바꾸고, 곡선을 지우고 넣는다. + * + * 왜 옮겼나(2026-09-07 사용자 지시 ③) — 예전에는 모달 **맨 아래 줄**이었다. 지금 고른 것이 + * 지도 어디인지 눈으로 이어지지 않아, 값을 바꾸면서도 어느 곡선을 만지는지 몰랐다. + * 「해당 요소를 선택하면 우측에 라벨 같은 입력이 보이게」가 사용자의 말 그대로다. + * + * 캔버스 밖으로 나가지 않게 가장자리에서 **반대쪽으로 접어 넣는다** — 노선 끝의 꺾임점을 + * 골라도 입력칸이 잘리지 않아야 한다. + * ========================================================================== */ + +/** 라벨과 노드 사이 여백(px). */ +const GAP_PX = 14; +/** 캔버스 가장자리에서 이만큼은 띄운다(px). */ +const EDGE_PX = 8; + +export interface CurveLabelState { + /** 몇 번째 꺾임점인지 — 0부터 센 자리. 표시는 +1 해서 낸다. */ + seat: number; + /** 그 꺾임점의 화면 좌표(캔버스 기준 px). */ + at: [number, number]; + /** 캔버스 크기(px) — 라벨을 안쪽으로 접어 넣는 데 쓴다. */ + canvas: { width: number; height: number }; + curveOn: boolean; + /** 지금 보일 반지름(m). 곡선이 없으면 null. */ + radiusShown: number | null; + /** 사용자가 못박은 값인지(아니면 자동). */ + forced: boolean; + innerAngleDeg: number | null; + minRadiusM: number; +} + +export interface CurveLabelHandlers { + /** R 칸을 고쳤다 — null 이면 「자동」. */ + onRadius: (value: number | null) => void; + /** 곡선을 지우거나 넣었다. */ + onCurveOn: (on: boolean) => void; + /** 반지름을 자동으로 되돌렸다. */ + onAuto: () => void; +} + +export interface CurveLabel { + show: (state: CurveLabelState) => void; + hide: () => void; +} + +/** 라벨을 만들어 `host`(캔버스를 감싼 칸, `position: relative`)에 붙인다. */ +export function createCurveLabel(host: HTMLElement, handlers: CurveLabelHandlers): CurveLabel { + const root = document.createElement("div"); + root.className = "b05-routeedit__label"; + root.hidden = true; + root.innerHTML = ` +
+ + +
+ + `; + host.append(root); + + const seatText = root.querySelector(".b05-routeedit__curve-label")!; + const toggle = root.querySelector('[data-act="curve-toggle"]')!; + const radius = root.querySelector(".b05-routeedit__curve-radius")!; + const auto = root.querySelector('[data-act="curve-auto"]')!; + const info = root.querySelector(".b05-routeedit__curve-info")!; + + // 라벨 위에서 누른 것이 캔버스로 새어 나가면 노드가 딸려 움직인다. + root.addEventListener("pointerdown", (event) => event.stopPropagation()); + root.addEventListener("dblclick", (event) => event.stopPropagation()); + root.addEventListener("contextmenu", (event) => event.stopPropagation()); + + let curveOn = true; + radius.addEventListener("change", () => { + const value = Number(radius.value); + handlers.onRadius(Number.isFinite(value) && value > 0 ? value : null); + }); + toggle.addEventListener("click", () => handlers.onCurveOn(!curveOn)); + auto.addEventListener("click", () => handlers.onAuto()); + + function place(at: [number, number], canvas: { width: number; height: number }): void { + // 먼저 보여야 크기를 잴 수 있다 — 그 다음 자리를 잡는다. + const width = root.offsetWidth; + const height = root.offsetHeight; + // 기본은 오른쪽. 오른쪽이 좁으면 왼쪽으로 접는다(사용자 말대로 「우측에」가 기본). + let left = at[0] + GAP_PX; + if (left + width > canvas.width - EDGE_PX) left = at[0] - GAP_PX - width; + left = Math.max(EDGE_PX, Math.min(left, canvas.width - width - EDGE_PX)); + let top = at[1] - height / 2; + top = Math.max(EDGE_PX, Math.min(top, canvas.height - height - EDGE_PX)); + root.style.left = `${Math.round(left)}px`; + root.style.top = `${Math.round(top)}px`; + } + + return { + show(state) { + curveOn = state.curveOn; + root.hidden = false; + seatText.textContent = `${state.seat + 1}번째 꺾임점`; + toggle.textContent = state.curveOn ? "곡선 지우기" : "곡선 넣기"; + radius.disabled = !state.curveOn; + auto.disabled = !state.curveOn || !state.forced; + radius.value = + state.radiusShown === null ? "" : String(Math.round(state.radiusShown * 10) / 10); + const inner = state.innerAngleDeg; + info.textContent = state.curveOn + ? `${state.forced ? "값 지정" : "자동"}${inner ? ` · 내각 ${Math.round(inner)}°` : ""}` + + ` · 법정 하한 ${state.minRadiusM}m` + : "곡선 없음 — 직선이 그대로 꺾입니다"; + place(state.at, state.canvas); + }, + hide() { + root.hidden = true; + }, + }; +} diff --git a/B05_Profile/B05_Profile_UI_Style_RouteEdit.css b/B05_Profile/B05_Profile_UI_Style_RouteEdit.css index 9933d6fb..7e6bd7d0 100644 --- a/B05_Profile/B05_Profile_UI_Style_RouteEdit.css +++ b/B05_Profile/B05_Profile_UI_Style_RouteEdit.css @@ -131,21 +131,47 @@ text-align: center; } -/* 곡선 편집줄 — 고른 꺾임점의 R 을 바꾸고, 곡선을 지우고 넣는다(2026-09-07 사용자 지시). - 바닥 단추줄과 같은 결로 두되, 고른 것이 없으면 통째로 숨는다. */ -.b05-routeedit__curve { - /* 바닥줄과 같이 **줄어들지 않게** 둔다 — 안 그러면 캔버스(flex:1)가 자리를 다 먹고 - 이 줄이 눌려 단추가 캔버스 밑에 깔린다(2026-09-07 실화면에서 클릭이 가로채였음). */ - flex: none; - position: relative; - z-index: 1; +/* R 라벨 — 고른 꺾임점 **옆에 떠 있는** 작은 상자(2026-09-07 사용자 지시 ③). + 예전에는 모달 맨 아랫줄이라 지금 고른 것이 지도 어디인지 눈으로 안 이어졌다. + 자리는 `_Label.ts` 가 매 프레임 잡아 준다 — 여기서는 모양만 정한다. */ +.b05-routeedit__label { + position: absolute; + z-index: 2; + display: flex; + flex-direction: column; + gap: 4px; + min-width: 11rem; + padding: var(--spacing-8, 8px); + border: 1px solid var(--color-border, #e5e7eb); + border-radius: var(--radius-6, 6px); + background: var(--color-surface-2, #f9fafb); + box-shadow: 0 4px 14px rgb(0 0 0 / 25%); + font-size: var(--font-size-13, 13px); +} + +.b05-routeedit__label-head { display: flex; align-items: center; - gap: var(--spacing-8); - padding: var(--spacing-8) var(--spacing-12); - border-top: 1px solid var(--color-border, #e5e7eb); - background: var(--color-surface-2, #f9fafb); - font-size: var(--font-size-13, 13px); + justify-content: space-between; + gap: var(--spacing-8, 8px); +} + +.b05-routeedit__label-toggle, +.b05-routeedit__label-auto { + padding: 1px 6px; + border: 1px solid var(--color-border, #e5e7eb); + border-radius: var(--radius-4, 4px); + background: var(--color-surface, #fff); + color: inherit; + font: inherit; + font-size: var(--font-size-12, 12px); + cursor: pointer; +} + +.b05-routeedit__label-toggle:disabled, +.b05-routeedit__label-auto:disabled { + color: var(--color-text-muted, #9ca3af); + cursor: default; } .b05-routeedit__curve-label { @@ -161,7 +187,7 @@ } .b05-routeedit__curve-radius { - width: 5.5rem; + width: 4.5rem; padding: 2px 6px; border: 1px solid var(--color-border, #e5e7eb); border-radius: var(--radius-4, 4px); @@ -175,9 +201,7 @@ } .b05-routeedit__curve-info { - flex: 1 1 auto; + max-width: 16rem; color: var(--color-text-muted, #6b7280); - overflow: hidden; - text-overflow: ellipsis; - white-space: nowrap; + line-height: 1.35; }