diff --git a/B05_Profile/B05_Profile_UI_RouteEdit.ts b/B05_Profile/B05_Profile_UI_RouteEdit.ts index fdf9159f..09a1f611 100644 --- a/B05_Profile/B05_Profile_UI_RouteEdit.ts +++ b/B05_Profile/B05_Profile_UI_RouteEdit.ts @@ -29,15 +29,39 @@ import { showToast } from "@ui/ui_template_elements"; 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 { dragHandleTo as curveDragTo } from "./B05_Profile_UI_RouteEdit_Curve"; +import { buildEditedPolyline, dragHandleTo as curveDragTo } from "./B05_Profile_UI_RouteEdit_Curve"; +import { + bindRouteEditNavigation, + contourBandRect, + handleAtScreen, + nodeAtScreen, + segmentAtScreen, +} from "./B05_Profile_UI_RouteEdit_Input"; +import { + centerDirectionOf, + createCurveLabel, + deflectionRad, +} 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 +80,24 @@ export async function openRouteEditModal(
계획노선 편집 - 노드를 끌어 옮기고, 선을 두 번 누르면 노드가 생깁니다. 노드 오른쪽 클릭은 삭제. + 노드 끌기 = 옮기기 · 노드 클릭 = R 라벨 · 선 두 번 클릭 = 노드 추가 · + 노드 오른쪽 클릭 = 삭제 · 가운데(휠) 버튼 끌기 = 지도 이동 · 휠 = 확대
-
노선을 읽는 중… 예상노선(원본) 계획노선 + + + @@ -110,6 +130,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 +147,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); @@ -189,6 +212,16 @@ export async function openRouteEditModal( context.fillRect(0, 0, view.width, view.height); context.save(); + // 등고선은 **노선 둘레 300m 안**에서만 그린다 — 노선과 상관없는 산줄기까지 다 그리면 + // 화면이 등고선으로 덮여 노선이 안 보인다(2026-09-07 사용자 지시 ⑥). + const band = meta + ? contourBandRect(plannedLine.length ? plannedLine : planned, toScreen, CONTOUR_BAND_M) + : null; + 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,100 +272,64 @@ export async function openRouteEditModal( // **속을 비우고 테두리를 굵게** 그린다 — 선·노드와 색이 같으면 눈에도 안 띄고 집기도 어렵다. context.lineWidth = 2; curveInfo.forEach((curve) => { + // **늘 보인다**(2026-09-07 사용자 지시) — 직선이 곡선에 닿는 자리는 손잡이이기 이전에 + // **읽을 정보**다. 한때 고른 곡선만 내보였더니 「표기가 다 사라졌다」는 지적을 받았다. + // 노드를 못 집던 문제는 집기 우선순위(노드가 먼저)로 따로 풀었으므로 다 내놓아도 된다. const on = curveOn[curve.node_first] !== false; - if (!on) return; // 곡선을 지운 자리에는 손잡이도 없다. + if (!on) return; // 곡선을 지운 자리에는 접선점도 없다. + // 고른 곡선은 속을 채워 도드라지게 — 지금 끌 수 있는 것이 무엇인지 보이게. + const isPicked = curve.node_first === picked; [curve.start, curve.end].forEach((point) => { const [x, y] = toScreen([point[0], point[1]]); context.beginPath(); - context.rect( - x - CURVE_HANDLE_PX, - y - CURVE_HANDLE_PX, - CURVE_HANDLE_PX * 2, - CURVE_HANDLE_PX * 2, - ); - context.fillStyle = style.getPropertyValue("--map-halo") || "rgba(255,255,255,0.95)"; + const size = isPicked ? CURVE_HANDLE_PX + 1 : CURVE_HANDLE_PX; + context.rect(x - size, y - size, size * 2, size * 2); + context.fillStyle = isPicked + ? style.getPropertyValue("--map-route") || "#f97316" + : style.getPropertyValue("--map-halo") || "rgba(255,255,255,0.95)"; context.fill(); context.strokeStyle = style.getPropertyValue("--map-route") || "#f97316"; context.stroke(); }); }); context.restore(); + // 라벨은 **그린 뒤** 자리를 맞춘다 — 확대·이동·창 크기가 바뀌어도 고른 노드에 붙어 있게. + syncCurveBar(); } - /** 화면 좌표에 가장 가까운 **곡선 손잡이**(시작·끝점). 없으면 null. */ - function handleAt(px: number, py: number): { curve: number; end: "start" | "end" } | null { - let best: { curve: number; end: "start" | "end" } | null = null; - let bestDistance = NODE_HIT_PX + 2; - curveInfo.forEach((curve, index) => { - (["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 = { curve: index, end: which }; - } - }); - }); - return best; - } + /** 잡기 — 셈은 `_RouteEdit_Input` 몫이고, 여기서는 지금 상태를 건네준다. */ + const handleAt = (px: number, py: number): { node: number; end: "start" | "end" } | null => + handleAtScreen(curveInfo, 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( - curveIndex: number, + node: number, which: "start" | "end", to: Vertex, ): { apex: Vertex; radius: number } | null { - const curve = curveInfo[curveIndex]; + const curve = curveInfo.find((entry) => entry.node_first === node); if (!curve) return null; - const node = curve.node_first; const before = planned[node - 1]; const after = planned[node + 1]; if (!before || !after) return null; 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; - } - - /** 노드를 고쳤다 — 서버가 만든 폴리라인은 낡았으므로 지우고 직선으로 미리 보인다. - * 곡선은 [확인] 때 서버가 같은 R 규칙으로 다시 끼운다(계산을 두 벌로 짜지 않는다). */ + /** 노드를 고쳤다 — **곡선을 그 자리에서 다시 그린다**(2026-09-07 사용자 지적 ①②). + * + * 예전에는 그려 둔 선과 손잡이를 통째로 비웠다. 그러면 노드 하나만 건드려도 **곡선이 전부 + * 사라진 것처럼** 보였다 — 값은 남아 있는데 화면만 「지워졌다」고 말하니 되돌릴 길을 찾게 됐다. + * 지금은 서버와 **같은 규칙**(`buildEditedPolyline` 짝)으로 즉시 다시 만든다. [확인] 때 + * 서버가 정본으로 다시 내는 것은 그대로다. */ function markEdited(): void { - plannedLine = []; - nodeInfo = []; - curveInfo = []; // 손잡이 자리도 낡았다 — [확인] 때 서버가 다시 낸다. + const built = buildEditedPolyline(planned, curveOn, curveRadius, minRadiusM); + plannedLine = built.vertices; + curveInfo = built.curves; + nodeInfo = built.nodes; } /** 상태줄 꼬리 — 곡선 기준과 위반 수를 알린다. */ @@ -356,100 +353,114 @@ 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"]')!; + // ── 곡선 라벨 — 고른 꺾임점 옆(곡선 중심 반대쪽)에 뜬다. 그리기는 `_Label` 몫 ── + const curveLabelBox = createCurveLabel(canvas.parentElement!, { + onRadius: (value) => { + if (picked < 0) return; + curveRadius[picked] = value; + // 반지름만 바꾼 것이라 노드 자리는 그대로지만, 그려진 선은 낡았다. + applyEdit(value === null ? "반지름을 자동으로 되돌렸습니다." : "반지름을 바꿨습니다."); + }, + onArcLength: (value) => { + if (picked < 0) return; + // 곡선 길이 L 과 반지름 R 은 L = R·Δ 로 묶여 있다(Δ = 교각, 앞뒤 직선이 정함). + // 그래서 길이를 받으면 반지름으로 바꿔 **한 값만** 들고 간다 — 두 벌로 두면 어긋난다. + const deflection = deflectionRad(nodeInfo[picked]?.inner_angle_deg); + curveRadius[picked] = value !== null && deflection > 1e-9 ? value / deflection : null; + applyEdit(value === null ? "반지름을 자동으로 되돌렸습니다." : "곡선 길이를 바꿨습니다."); + }, + onCurveOn: (on) => { + if (picked < 0) return; + curveOn[picked] = on; + applyEdit(on ? "곡선을 넣었습니다." : "곡선을 지웠습니다."); + }, + }); - /** 고른 자리에 맞춰 편집줄을 다시 그린다. 끝점은 곡선이 없으므로 줄을 숨긴다. */ + /** 고른 자리에 맞춰 라벨을 옮겨 그린다. 끝점은 곡선이 없으므로 라벨을 숨긴다. */ 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` - : "곡선 없음 — 직선이 그대로 꺾입니다"; + const pickedCurve = curveInfo.find((entry) => entry.node_first === picked); + const shown = forced ?? pickedCurve?.radius_m ?? null; + const deflection = deflectionRad(nodeInfo[picked]?.inner_angle_deg); + curveLabelBox.show({ + seat: picked, + at: toScreen(planned[picked]), + centerDirection: pickedCurve + ? centerDirectionOf( + toScreen([pickedCurve.apex[0], pickedCurve.apex[1]]), + toScreen(pickedCurve.start), + toScreen(pickedCurve.end), + ) + : null, + canvas: { width: view.width, height: view.height }, + curveOn: curveOn[picked] !== false, + radiusShown: shown, + arcLengthShown: shown === null || deflection <= 1e-9 ? null : shown * deflection, + 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: { curve: number; end: "start" | "end" } | null = null; - let panFrom: { x: number; y: number; offsetX: number; offsetY: number } | null = null; + /** 끌고 있는 곡선 손잡이(시작·끝점). **고른 곡선에만** 있다. */ + let dragHandle: { node: number; end: "start" | "end" } | 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) { - picked = curveInfo[dragHandle.curve]?.node_first ?? -1; + // **노드가 손잡이보다 먼저다**(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 (dragNode >= 0) { - picked = dragNode; // 누른 자리를 고른다 — 편집줄이 그 곡선을 만진다. + } else if (dragHandle) { + picked = dragHandle.node; syncCurveBar(); draw(); - } else { - panFrom = { x: px, y: py, offsetX: view.offsetX, offsetY: view.offsetY }; } canvas.setPointerCapture(event.pointerId); }); @@ -460,52 +471,47 @@ export async function openRouteEditModal( const py = event.clientY - rect.top; if (dragHandle) { // 곡선 시작·끝점을 끈다 — 그쪽 직선 각도와 반지름이 함께 바뀐다(2026-09-07 사용자 확정). - const moved = dragHandleTo(dragHandle.curve, dragHandle.end, toMetric(px, py)); + const node = dragHandle.node; + const moved = dragHandleTo(node, dragHandle.end, toMetric(px, py)); if (moved) { - const node = curveInfo[dragHandle.curve].node_first; planned[node] = moved.apex; curveRadius[node] = Math.round(moved.radius * 100) / 100; curveOn[node] = true; picked = node; - // 손잡이 자리도 따라 움직여야 계속 끌 수 있다 — 그림은 [확인] 때 서버가 다시 낸다. - const which = dragHandle.end === "start" ? "start" : "end"; - curveInfo[dragHandle.curve] = { - ...curveInfo[dragHandle.curve], - apex: [moved.apex[0], moved.apex[1]], - radius_m: moved.radius, - [which]: toMetric(px, py), - } as (typeof curveInfo)[number]; - plannedLine = []; - nodeInfo = []; + // 손잡이 자리는 다시 셈한 곡선에서 나온다 — 접선 자리가 모자라 R 이 눌리면 손이 + // 끄는 자리보다 덜 따라오고, 그 눌림이 그 자리에서 눈에 보인다. + dragMoved = true; + markEdited(); syncCurveBar(); + status.textContent = `노드 ${planned.length}개 — 곡선을 잡는 중. ${curveHint()}`; draw(); } return; } if (dragNode >= 0) { + dragMoved = true; planned[dragNode] = toMetric(px, py); - markEdited(); // 폴리라인은 [확인] 때 서버가 다시 만든다 — 지금은 직선으로 미리 보인다. + markEdited(); // 곡선을 그 자리에서 다시 그린다 — 나머지 곡선은 그대로 남는다. + // 끄는 동안에도 상태줄이 살아 있어야 한다 — 예전에는 여기서 아무 말이 없어 + // 「곡선이 사라졌다」는 인상만 남았다(2026-09-07 사용자 지적 ②). + status.textContent = `노드 ${planned.length}개 — 옮기는 중. ${curveHint()}`; 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); @@ -521,10 +527,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) => { @@ -540,33 +543,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; @@ -654,12 +643,18 @@ export async function openRouteEditModal( inner_angle_deg: curve ? curve.inner_angle_deg : node.inner_angle_deg, violations: curve ? (curve.violations ?? []) : (node.violations ?? []), }); - curveOn.push(true); + // 서버가 **곡선을 안 둔 자리는 「곡선 없음」으로 연다**(2026-09-07). 전부 켬으로 열면 + // 아무것도 안 만지고 [확인]만 눌러도 그 자리에 곡선이 새로 생겨 노선이 조용히 바뀐다 + // (서버의 편집 갈래는 켜진 자리마다 원호를 끼우기 때문). 내각 179° 이상인 자리가 그렇다. + curveOn.push(curve !== undefined); // 서버가 고른 반지름을 **그대로 들고 간다** — 안 그러면 편집 한 번에 하한으로 눌린다. curveRadius.push(curve ? Math.round(curve.radius_m * 100) / 100 : null); 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_Curve.ts b/B05_Profile/B05_Profile_UI_RouteEdit_Curve.ts index 49b97466..4a35d75b 100644 --- a/B05_Profile/B05_Profile_UI_RouteEdit_Curve.ts +++ b/B05_Profile/B05_Profile_UI_RouteEdit_Curve.ts @@ -1,16 +1,57 @@ /* ============================================================================= * B05_Profile_UI_RouteEdit_Curve.ts - * 곡선 손잡이 셈 — 편집 모달(`B05_Profile_UI_RouteEdit.ts`)에서 떼어냄 - * (700줄 제한, 2026-09-07). 화면·DOM 을 안 만지는 순수 기하만 둔다. + * 계획노선 편집의 **기하 셈** — 화면·DOM 을 안 만지는 순수 함수만 둔다. + * (편집 모달 `B05_Profile_UI_RouteEdit.ts` 에서 떼어냄, 700줄 제한 2026-09-07) * - * ⚠ 이 셈은 서버(`common_util/common_util_route_polyline.py`)의 **반대 방향**이다 — - * 서버는 교각점·R 에서 접선점을 내고, 여기서는 끈 접선점에서 교각점·R 을 낸다. - * 두 벌이 아니라 **짝**이며, 왕복이 제자리인지는 - * `tmp/tests/test_route_polyline_handle_drag.py` 가 지킨다. + * 두 가지가 들어 있다. + * + * ① **손잡이 → 교각점·반지름**(`dragHandleTo`) — 서버 셈의 **반대 방향**이다. 서버는 + * 교각점·R 에서 접선점을 내고, 여기서는 끈 접선점에서 교각점·R 을 낸다. + * 왕복이 제자리인지는 `tmp/tests/test_route_polyline_handle_drag.py` 가 지킨다. + * + * ② **노드 → 폴리라인**(`buildEditedPolyline`) — ⚠⚠ **짝**: + * `common_util/common_util_route_polyline.py` 의 `build_planned_polyline` 중 + * **편집 갈래**(`simplify=False` + `curve_flags`/`radii`)와 같은 값을 내야 한다. + * 거울 시험: `tmp/tests/test_route_polyline_browser_mirror.py`. + * + * 왜 브라우저에도 두나(2026-09-07 사용자 지적 ①②) — 곡선을 서버만 그리면 노드를 잡는 + * 순간 그려 둔 선을 통째로 버려야 해서 **곡선이 전부 사라진 것처럼 보인다**. 값은 남아 + * 있는데 화면만 「지워졌다」고 말하니 더 나쁘다. 조작 중에는 브라우저가 같은 규칙으로 + * 즉시 그리고, [확인] 때 서버가 정본으로 다시 낸다(CLAUDE.md 「계산 자리」 ① 짝). + * + * 옮기지 않은 것 — 단순화·IP 추출·반지름 피팅(`_fit_radius_m`)은 **예상노선을 처음 + * 폴리라인으로 바꿀 때만** 쓰는 것이라 편집 갈래에서는 돌지 않는다(`node_indices` 가 None). * ========================================================================== */ export type Vertex = [number, number]; +/** 곡선 성분 하나 — 서버 `RoutePlanCurve` 와 **같은 꼴**이라 그대로 바꿔 쓸 수 있다. + * + * ⚠ 여기서 다시 적는 이유 — 이 파일은 거울 시험이 `tsc` 로 **혼자 컴파일**하므로 바깥 + * 모듈을 들이지 않는다(경로 별칭 `@config/…` 가 딸려 와 컴파일이 깨진다). */ +export interface EditedCurve { + apex: [number, number]; + radius_m: number; + tangent_m: number; + inner_angle_deg: number; + start: [number, number]; + end: [number, number]; + node_first: number; + node_last: number; + violations: string[]; +} + +/** 짝: `DUPLICATE_TOLERANCE_M`. 이보다 가까운 뒤엣점은 같은 자리로 보고 버린다. */ +const DUPLICATE_TOLERANCE_M = 0.5; +/** 짝: `ARC_STEP_DEG`. 원호를 몇 도마다 한 점씩 찍을지. */ +const ARC_STEP_DEG = 5.0; +/** 짝: `math.degrees`/`math.radians` — 파이썬은 **상수 하나를 곱한다**. 곱셈 순서가 다르면 + * 90° 가 89.999…9 로 떨어져 원호 점 수가 하나 어긋난다(2026-09-07 거울 시험에서 실제로 남). */ +const DEG_PER_RAD = 180 / Math.PI; +const RAD_PER_DEG = Math.PI / 180; +/** 짝: `build_planned_polyline` 의 `hairpin_min_radius_m` 기본값(배향곡선 하한). */ +export const HAIRPIN_MIN_RADIUS_M = 10.0; + /** 두 **직선**(선분 아님)이 만나는 자리. 나란하면 null. */ export function intersect(a1: Vertex, a2: Vertex, b1: Vertex, b2: Vertex): Vertex | null { const dx1 = a2[0] - a1[0]; @@ -33,7 +74,7 @@ export function innerAngleDeg(before: Vertex, at: Vertex, after: Vertex): number const lb = Math.hypot(bx, by); if (la <= 0 || lb <= 0) return 180; const cosine = Math.max(-1, Math.min(1, (ax * bx + ay * by) / (la * lb))); - return (Math.acos(cosine) * 180) / Math.PI; + return Math.acos(cosine) * DEG_PER_RAD; } /** 끈 접선점으로 **새 교각점과 새 반지름**을 구한다 — 「직선의 각도와 반지름 값 변경」. @@ -59,10 +100,204 @@ export function dragHandleTo( : intersect(before, oldApex, to, after); // 나가는 직선을 돌린다 if (!apex) return null; const inner = innerAngleDeg(before, apex, after); - const halfTan = Math.tan(((180 - inner) * Math.PI) / 360); + const halfTan = Math.tan(((180 - inner) * RAD_PER_DEG) / 2); if (!(halfTan > 1e-9)) return null; const tangent = Math.hypot(apex[0] - to[0], apex[1] - to[1]); const radius = tangent / halfTan; if (!(radius > 0) || !Number.isFinite(radius)) return null; return { apex, radius }; } + +const distance = (a: Vertex, b: Vertex): number => Math.hypot(a[0] - b[0], a[1] - b[1]); + +/** 짝: `_unit`. from → to 방향의 단위벡터. 같은 자리면 (0,0). */ +function unit(from: Vertex, to: Vertex): Vertex { + const dx = to[0] - from[0]; + const dy = to[1] - from[1]; + const length = Math.hypot(dx, dy); + if (length <= 0) return [0, 0]; + return [dx / length, dy / length]; +} + +/** 짝: `_turn_sign`. 도는 방향 — 왼쪽 +1, 오른쪽 −1, 곧게 0. */ +function turnSign(before: Vertex, at: Vertex, after: Vertex): number { + const cross = (at[0] - before[0]) * (after[1] - at[1]) - (at[1] - before[1]) * (after[0] - at[0]); + if (Math.abs(cross) <= 1e-9) return 0; + return cross > 0 ? 1 : -1; +} + +/** 짝: `_arc_geometry`. 반지름 하나에 대한 (접선시작, 접선끝, 중심). 못 끼우면 null. */ +function arcGeometry( + before: Vertex, + at: Vertex, + after: Vertex, + innerDeg: number, + radius: number, + halfTan: number, +): { start: Vertex; end: Vertex; center: Vertex } | null { + const tangent = radius * halfTan; + const toBefore = unit(at, before); + const toAfter = unit(at, after); + const start: Vertex = [at[0] + toBefore[0] * tangent, at[1] + toBefore[1] * tangent]; + const end: Vertex = [at[0] + toAfter[0] * tangent, at[1] + toAfter[1] * tangent]; + const bisector: Vertex = [toBefore[0] + toAfter[0], toBefore[1] + toAfter[1]]; + const bisectorLength = Math.hypot(bisector[0], bisector[1]); + if (bisectorLength <= 1e-9) return null; + const centerDistance = radius / Math.sin((innerDeg * RAD_PER_DEG) / 2); + const center: Vertex = [ + at[0] + (bisector[0] / bisectorLength) * centerDistance, + at[1] + (bisector[1] / bisectorLength) * centerDistance, + ]; + return { start, end, center }; +} + +/** 짝: `_arc_points`. 원호 위 점(양 끝은 빼고 — 부르는 쪽이 붙인다). */ +function arcPoints(center: Vertex, start: Vertex, end: Vertex, clockwise: boolean): Vertex[] { + const radius = distance(center, start); + if (radius <= 0) return []; + const startAngle = Math.atan2(start[1] - center[1], start[0] - center[0]); + const endAngle = Math.atan2(end[1] - center[1], end[0] - center[0]); + let sweep = endAngle - startAngle; + if (clockwise) { + while (sweep > 0) sweep -= 2 * Math.PI; + } else { + while (sweep < 0) sweep += 2 * Math.PI; + } + const steps = Math.max(1, Math.trunc(Math.abs(sweep * DEG_PER_RAD) / ARC_STEP_DEG)); + const points: Vertex[] = []; + for (let step = 1; step < steps; step += 1) { + const angle = startAngle + (sweep * step) / steps; + points.push([center[0] + radius * Math.cos(angle), center[1] + radius * Math.sin(angle)]); + } + return points; +} + +/** 노드 하나의 요약 — 화면이 붉은 점·내각·R 을 그리는 재료(서버 `RouteNode` 와 같은 꼴). */ +export interface EditedNode { + inner_angle_deg: number | null; + radius_m: number | null; + tangent_m: number | null; + violations: string[]; +} + +export interface EditedPolyline { + /** 그려 보이는 선(원호 포함). */ + vertices: Vertex[]; + /** 곡선 성분 — 손잡이·R 칸의 재료. `node_first`/`node_last` 는 **넘긴 목록의 자리**다. */ + curves: EditedCurve[]; + /** 넘긴 목록과 **자리가 같은** 노드 요약. */ + nodes: EditedNode[]; +} + +/** + * 짝: `build_planned_polyline` 의 편집 갈래. 노드·곡선 켬끔·반지름으로 폴리라인을 만든다. + * + * `curveOn[i]` 가 거짓이면 그 자리에 곡선을 두지 않는다(직선이 그대로 꺾인다). + * `curveRadius[i]` 가 있으면 그 반지름으로 못박고, 없으면 법정 하한을 쓴다. + * 접선 자리가 모자라면 **줄이되 막지 않고** 위반으로 표시한다(서버와 같은 규칙). + * + * ⚠ 서버는 0.5m 안에 겹친 점을 버린다. 여기서도 같이 버리되, 돌려주는 자리 번호는 + * **넘긴 목록 기준**으로 되돌려 놓는다 — 화면이 잡고 있는 배열이 그것이기 때문이다. + */ +export function buildEditedPolyline( + points: Vertex[], + curveOn: boolean[], + curveRadius: Array, + minRadiusM: number, + hairpinMinRadiusM: number = HAIRPIN_MIN_RADIUS_M, +): EditedPolyline { + const nodes: EditedNode[] = points.map(() => ({ + inner_angle_deg: null, + radius_m: null, + tangent_m: null, + violations: [], + })); + + // 겹친 점 버리기 — 편집값과 **함께** 걸러야 자리가 어긋나지 않는다. + const cleaned: Vertex[] = []; + const flags: boolean[] = []; + const forcedRadii: Array = []; + const origin: number[] = []; // cleaned 자리 → 넘긴 목록 자리 + points.forEach((point, index) => { + if (cleaned.length && distance(cleaned[cleaned.length - 1], point) <= DUPLICATE_TOLERANCE_M) { + return; + } + cleaned.push(point); + flags.push(curveOn[index] !== false); + forcedRadii.push(curveRadius[index] ?? null); + origin.push(index); + }); + + if (cleaned.length < 3) return { vertices: [...cleaned], curves: [], nodes }; + + for (let index = 1; index < cleaned.length - 1; index += 1) { + nodes[origin[index]].inner_angle_deg = innerAngleDeg( + cleaned[index - 1], + cleaned[index], + cleaned[index + 1], + ); + } + + const vertices: Vertex[] = [cleaned[0]]; + const curves: EditedCurve[] = []; + let cursor = 0; // 아직 선에 안 실은 첫 꺾임점 + + for (let at = 1; at < cleaned.length - 1; at += 1) { + if (!flags[at]) continue; // 곡선을 지운 자리 — 직선이 그대로 꺾인다. + const entryFrom = cleaned[at - 1]; + const apex = cleaned[at]; + const exitTo = cleaned[at + 1]; + const node = nodes[origin[at]]; + + const inner = innerAngleDeg(entryFrom, apex, exitTo); + const halfTan = Math.tan(((180 - inner) * RAD_PER_DEG) / 2); + // 접선이 들어갈 자리 — 앞뒤 직선을 이웃 곡선과 나눠 쓰므로 절반까지만. + const available = Math.min(distance(entryFrom, apex), distance(apex, exitTo)) / 2; + if (!(halfTan > 1e-9) || available <= 0) continue; + + const forced = forcedRadii[at]; + let radius = forced !== null && forced > 0 ? forced : minRadiusM; + let tangent = radius * halfTan; + if (tangent > available) { + radius = available / halfTan; + tangent = available; + } + + const geometry = arcGeometry(entryFrom, apex, exitTo, inner, radius, halfTan); + if (radius <= 0 || geometry === null) continue; + + if (radius < minRadiusM) { + node.violations.push(`최소곡선반지름 미달(${radius.toFixed(1)} < ${minRadiusM.toFixed(1)}m)`); + } + if (radius < hairpinMinRadiusM) { + node.violations.push( + `배향곡선 하한 미달(${radius.toFixed(1)} < ${hairpinMinRadiusM.toFixed(1)}m)`, + ); + } + node.radius_m = radius; + node.tangent_m = tangent; + curves.push({ + apex: [apex[0], apex[1]], + radius_m: radius, + tangent_m: tangent, + inner_angle_deg: inner, + start: [geometry.start[0], geometry.start[1]], + end: [geometry.end[0], geometry.end[1]], + node_first: origin[at], + node_last: origin[at], + violations: [...node.violations], + }); + + // 곡선 앞의 직선 위 꺾임점들은 그대로 잇는다. + for (let index = cursor + 1; index < at; index += 1) vertices.push(cleaned[index]); + cursor = at; + + const clockwise = turnSign(entryFrom, apex, exitTo) < 0; + vertices.push(geometry.start); + vertices.push(...arcPoints(geometry.center, geometry.start, geometry.end, clockwise)); + vertices.push(geometry.end); + } + + for (let index = cursor + 1; index < cleaned.length; index += 1) vertices.push(cleaned[index]); + return { vertices, curves, nodes }; +} 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..89555c43 --- /dev/null +++ b/B05_Profile/B05_Profile_UI_RouteEdit_Input.ts @@ -0,0 +1,213 @@ +/* ============================================================================= + * 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), + }; +} 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..9096e81b --- /dev/null +++ b/B05_Profile/B05_Profile_UI_RouteEdit_Label.ts @@ -0,0 +1,180 @@ +/* ============================================================================= + * B05_Profile_UI_RouteEdit_Label.ts + * 고른 꺾임점 **옆에 뜨는 곡선 라벨** — 반지름과 곡선 길이로 곡선을 만진다. + * + * 왜 옮겼나(2026-09-07 사용자 지시 ③) — 예전에는 모달 **맨 아래 줄**이었다. 지금 고른 것이 + * 지도 어디인지 눈으로 이어지지 않아, 값을 바꾸면서도 어느 곡선을 만지는지 몰랐다. + * + * **R 과 곡선 길이 두 칸이 한 쌍이다**(2026-09-07 사용자 지시) — 교각(Δ)은 앞뒤 직선이 + * 정하므로 둘은 L = R·Δ 로 묶여 있다. 한쪽을 고치면 다른 쪽이 따라온다. 「반지름 자동」 + * 단추는 없앴다 — 칸을 비우는 것이 곧 자동이다. + * + * **자리는 곡선 중심의 반대쪽**(2026-09-07 사용자 지시) — 중심 쪽에 두면 라벨이 곡선을 + * 가린다. 상하좌우 네 방향 중 중심에서 먼 쪽에 붙이고, 캔버스 밖으로 나가면 안으로 접는다. + * ========================================================================== */ + +/** 그 꺾임점의 **교각 Δ**(라디안) — 내각의 나머지. 곡선 길이 L = R·Δ 에 쓴다. */ +export function deflectionRad(innerAngleDeg: number | null | undefined): number { + if (innerAngleDeg === null || innerAngleDeg === undefined) return 0; + return ((180 - innerAngleDeg) * Math.PI) / 180; +} + +/** 곡선 중심이 있는 **화면 방향**(단위벡터) — 라벨을 그 반대쪽에 붙이는 데 쓴다. + * + * 중심은 접선점 둘이 이루는 각의 이등분선 위에 있다. 접선점은 교각점에서 앞뒤 직선을 따라 + * 뻗은 자리이므로, 두 방향의 단위벡터를 더하면 그대로 중심 쪽이다. 셋이 한 점이면 null. */ +export function centerDirectionOf( + apex: [number, number], + start: [number, number], + end: [number, number], +): [number, number] | null { + const arm = (point: [number, number]): [number, number] => { + const dx = point[0] - apex[0]; + const dy = point[1] - apex[1]; + const length = Math.hypot(dx, dy); + return length <= 1e-9 ? [0, 0] : [dx / length, dy / length]; + }; + const [ux, uy] = arm(start); + const [vx, vy] = arm(end); + const sx = ux + vx; + const sy = uy + vy; + const length = Math.hypot(sx, sy); + return length <= 1e-9 ? null : [sx / length, sy / length]; +} + +/** 라벨과 노드 사이 여백(px). */ +const GAP_PX = 14; +/** 캔버스 가장자리에서 이만큼은 띄운다(px). */ +const EDGE_PX = 8; + +export interface CurveLabelState { + /** 몇 번째 꺾임점인지 — 0부터 센 자리. 표시는 +1 해서 낸다. */ + seat: number; + /** 그 꺾임점의 화면 좌표(캔버스 기준 px). */ + at: [number, number]; + /** **곡선 중심이 있는 쪽**(화면 기준 방향벡터). 라벨은 이 반대쪽에 붙는다. + * 곡선이 없으면 null — 그때는 오른쪽에 둔다. */ + centerDirection: [number, number] | null; + /** 캔버스 크기(px) — 라벨을 안쪽으로 접어 넣는 데 쓴다. */ + canvas: { width: number; height: number }; + curveOn: boolean; + /** 지금 보일 반지름(m). 곡선이 없으면 null. */ + radiusShown: number | null; + /** 지금 보일 곡선 길이(m) = R·Δ. 곡선이 없으면 null. */ + arcLengthShown: number | null; + /** 사용자가 못박은 값인지(아니면 자동). */ + forced: boolean; + innerAngleDeg: number | null; + minRadiusM: number; +} + +export interface CurveLabelHandlers { + /** R 칸을 고쳤다 — null 이면 「자동」(칸을 비운 것). */ + onRadius: (value: number | null) => void; + /** 곡선 길이 칸을 고쳤다 — null 이면 「자동」. */ + onArcLength: (value: number | null) => void; + /** 곡선을 지우거나 넣었다. */ + onCurveOn: (on: boolean) => 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 arc = root.querySelector(".b05-routeedit__curve-arc")!; + 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; + const numberOf = (input: HTMLInputElement): number | null => { + const value = Number(input.value); + return input.value.trim() !== "" && Number.isFinite(value) && value > 0 ? value : null; + }; + radius.addEventListener("change", () => handlers.onRadius(numberOf(radius))); + arc.addEventListener("change", () => handlers.onArcLength(numberOf(arc))); + toggle.addEventListener("click", () => handlers.onCurveOn(!curveOn)); + + /** 라벨을 **곡선 중심의 반대쪽**에 붙인다 — 상하좌우 넷 중 하나. */ + function place(state: CurveLabelState): void { + const width = root.offsetWidth; + const height = root.offsetHeight; + const [nx, ny] = state.at; + const direction = state.centerDirection; + let left = nx + GAP_PX; // 곡선이 없으면 오른쪽이 기본이다. + let top = ny - height / 2; + if (direction) { + const [dx, dy] = direction; + if (Math.abs(dx) >= Math.abs(dy)) { + // 중심이 오른쪽이면 라벨은 왼쪽으로. + left = dx > 0 ? nx - GAP_PX - width : nx + GAP_PX; + top = ny - height / 2; + } else { + left = nx - width / 2; + top = dy > 0 ? ny - GAP_PX - height : ny + GAP_PX; + } + } + // 캔버스 밖으로 나가면 안으로 접는다 — 노선 끝을 골라도 칸이 잘리지 않게. + left = Math.max(EDGE_PX, Math.min(left, state.canvas.width - width - EDGE_PX)); + top = Math.max(EDGE_PX, Math.min(top, state.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; + arc.disabled = !state.curveOn; + radius.value = + state.radiusShown === null ? "" : String(Math.round(state.radiusShown * 10) / 10); + arc.value = + state.arcLengthShown === null ? "" : String(Math.round(state.arcLengthShown * 10) / 10); + const inner = state.innerAngleDeg; + info.textContent = state.curveOn + ? `${state.forced ? "값 지정" : "자동"}${inner ? ` · 내각 ${Math.round(inner)}°` : ""}` + + ` · 법정 하한 ${state.minRadiusM}m · 칸을 비우면 자동` + : "곡선 없음 — 직선이 그대로 꺾입니다"; + place(state); + // 글자가 바뀌면 상자 높이가 한 박자 늦게 자란다 — 다음 그림 직전에 한 번 더 맞춘다 + // (2026-09-07 실측: 처음 잰 높이 120px, 실제 131px 라 11px 어긋났음). + requestAnimationFrame(() => { + if (!root.hidden) place(state); + }); + }, + 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..6b4791db 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: 12rem; + padding: var(--spacing-8); + border: 1px solid var(--color-border); + border-radius: var(--radius-8, 6px); + /* 모달 본체와 **같은 토큰**을 쓴다 — `--color-surface-2` 는 이 테마에 없어 밝은 + 기본값으로 떨어지면서 다크 화면에서 글자가 안 읽혔다(2026-09-07 실화면). */ + background: var(--color-surface-raised); + color: var(--color-text-body); + box-shadow: 0 4px 14px rgb(0 0 0 / 45%); + font-size: var(--text-caption); +} + +.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 { + padding: 1px 6px; + border: 1px solid var(--color-border); + border-radius: var(--radius-4, 4px); + background: var(--color-surface); + color: var(--color-text-body); + font: inherit; + cursor: pointer; +} + +.b05-routeedit__label-toggle:disabled { + color: var(--color-text-secondary); + cursor: default; } .b05-routeedit__curve-label { @@ -154,30 +180,32 @@ } .b05-routeedit__curve-field { - display: inline-flex; + display: flex; align-items: center; - gap: 4px; + justify-content: space-between; + gap: 6px; white-space: nowrap; } -.b05-routeedit__curve-radius { - width: 5.5rem; +.b05-routeedit__curve-radius, +.b05-routeedit__curve-arc { + width: 5rem; padding: 2px 6px; - border: 1px solid var(--color-border, #e5e7eb); + border: 1px solid var(--color-border); border-radius: var(--radius-4, 4px); + background: var(--color-surface); + color: var(--color-text-body); font: inherit; text-align: right; } -.b05-routeedit__curve-radius:disabled { - background: var(--color-surface-3, #f3f4f6); - color: var(--color-text-muted, #9ca3af); +.b05-routeedit__curve-radius:disabled, +.b05-routeedit__curve-arc:disabled { + color: var(--color-text-secondary); } .b05-routeedit__curve-info { - flex: 1 1 auto; - color: var(--color-text-muted, #6b7280); - overflow: hidden; - text-overflow: ellipsis; - white-space: nowrap; + max-width: 15rem; + color: var(--color-text-secondary); + line-height: 1.35; } diff --git a/common_util/common_util_cross_berm.py b/common_util/common_util_cross_berm.py index 892cb56d..9649bbda 100644 --- a/common_util/common_util_cross_berm.py +++ b/common_util/common_util_cross_berm.py @@ -9,7 +9,7 @@ **바깥으로 걸어가며 그때그때 경사를 고르는** 방식으로 바꿨다. 소단이 없으면 종전과 같은 값이 나온다(거울 시험이 그것도 지킨다). -**소단 기본값** — 폭 0.5m · 간격(사면길이) 3.0m · 안쪽 기울기 2°. +**소단 기본값** — 폭 0.5m · 간격(사면길이) 3.0m · 안쪽 기울기 0°. · 폭·간격은 별표2 범위(사면길이 2~3m마다 · 폭 50~100㎝) 안에서 **가장 적게 파는 조합**이다. 기본값은 되돌리기 쉬운 쪽이어야 한다 — 더 넣는 것은 폼에서 한 번이지만 이미 판 것을 되돌리면 전 측점을 다시 계산해야 한다. 실효 경사로도 그렇다(경사 1:1 기준): diff --git a/common_util/common_util_cross_berm.ts b/common_util/common_util_cross_berm.ts index 7e88897b..bc12a09b 100644 --- a/common_util/common_util_cross_berm.ts +++ b/common_util/common_util_cross_berm.ts @@ -8,7 +8,7 @@ * 왜 따로 뺐나 — 소단이 들어가면 절토선이 「하나의 경사」가 아니라 **계단**이 된다. * 꼭짓점을 한 벌로 만들어 두면 도면·면적·3D 가 전부 그 선을 그대로 읽는다. * - * 소단 기본값 — 폭 0.5m · 간격(사면길이) 3.0m · 안쪽 기울기 2°. + * 소단 기본값 — 폭 0.5m · 간격(사면길이) 3.0m · 안쪽 기울기 0°. * · 폭·간격은 별표2 범위(사면길이 2~3m마다 · 폭 50~100㎝) 안에서 **가장 적게 파는 조합**. * 기본값은 되돌리기 쉬운 쪽이어야 한다 — 더 넣는 것은 폼에서 한 번이지만 이미 판 것을 * 되돌리면 전 측점을 다시 계산해야 한다. 실효 경사로도 그렇다(경사 1:1 기준):