/* ============================================================================= * B05_Profile_UI_RouteEdit.ts * 계획노선 편집 모달 — 예상노선(점선) 위에 계획노선(실선)을 고쳐 그린다. * * 왜 모달인가(2026-09-06 사용자 확정) — [확인]을 누르면 배수유역부터 종·횡단·유토곡선까지 * 전 단계가 다시 도는 무거운 작업이다(용화 67측점 3분대). 신중히 하라는 뜻으로 큰 모달을 * 쓰고, **편집 중에는 아무 계산도 나가지 않는다**. * * 노선은 두 벌이다 — 예상노선(원본, 안 바뀜)과 계획노선(수정본, 사용자가 고침). * [예상노선으로]는 수정본을 버리고 원본으로 되돌린다(서버가 파일을 지우고 같은 재계산). * * 그림은 배수유역도와 같은 지도 도구(`B04_PreProcess_UI_MapRender`)를 쓴다 — 등고선 도엽은 * 위경도, 노선은 사업지 좌표계(m)지만 두 변환기가 같은 정규화 공간을 본다. * ========================================================================== */ import { computeMapRect, computeRouteView, createNormalizer, hitPreparedLayer, metricToScreen, pickLevelStep, type PreparedLayer, type ViewState, } from "../B04_PreProcess/B04_PreProcess_UI_MapRender"; import { prepareLayer } from "../B04_PreProcess/B04_PreProcess_UI_MapRender_Prepare"; import type { VWorldMeta } from "../B04_PreProcess/B04_PreProcess_Api_Fetch"; import { clearDrafts, clearResults } from "../A00_Common/b_page_state"; import { showToast } from "@ui/ui_template_elements"; import { loadRouteEditContours, type RouteEditContours } from "./B05_Profile_UI_RouteEdit_Contour"; import { fetchDrainageLayers } from "./B05_Profile_UI_Drainage_Parts"; import { fetchRoutePlan, replanRoute, resetRoutePlan } from "./B05_Profile_Api_Replan"; import { buildEditedPolyline, dragHandleTo as curveDragTo, type EditedCurve, type EditedNode, } from "./B05_Profile_UI_RouteEdit_Curve"; import { drawRouteEditScene, polylineLengthM } from "./B05_Profile_UI_RouteEdit_Render"; import { bindRouteEditNavigation, handleAtScreen, nodeAtScreen, segmentAtScreen, } from "./B05_Profile_UI_RouteEdit_Input"; import { centerDirectionOf, createCurveLabel, deflectionRad, } from "./B05_Profile_UI_RouteEdit_Label"; import { applyArcLocks, curveSummary, flattenServerPlan, type CurveLock, } from "./B05_Profile_UI_RouteEdit_Edits"; 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 SEGMENT_HIT_PX = 12; /** 등고선을 집었다고 볼 거리(px) — 노드·손잡이보다 **좁게** 둔다(노선 편집이 먼저). */ const CONTOUR_HIT_PX = 6; type Vertex = [number, number]; /** 모달을 연다. [확인]·[예상노선으로]가 끝나면 `onApplied`를 부른다(화면 다시 읽기). */ export interface RouteEditOptions { /** 규칙 측점 간격(m) — 좌측 패널이 쥔 값을 그대로 받는다(코드에 굳히지 않는다). */ stationIntervalM?: number; /** 확정 지표면 모델 id — 있으면 바탕 등고선을 **LAS 것**으로 쓴다(계획서 0-9 ⑥). */ surfaceModelId?: number | null; /** 등고선 간격(m)·평활 여부 — 3D 뷰어가 쓰는 값 그대로. */ contourIntervalM?: number; smooth?: boolean; } export async function openRouteEditModal( projectId: string, onApplied: () => void | Promise, options: RouteEditOptions = {}, ): Promise { const stationIntervalM = options.stationIntervalM ?? 20; const overlay = document.createElement("div"); overlay.className = "b05-routeedit"; overlay.innerHTML = ` `; document.body.append(overlay); const canvas = overlay.querySelector(".b05-routeedit__canvas")!; const status = overlay.querySelector(".b05-routeedit__status")!; const busy = overlay.querySelector(".b05-routeedit__busy")!; const context = canvas.getContext("2d")!; let expected: Vertex[] = []; /** 그려 보이는 계획노선 — 원호가 섞인 폴리라인. **잡는 대상이 아니다.** */ let plannedLine: Vertex[] = []; /** 사용자가 잡아 옮기는 **노드**(꺾임점). 서버가 이 노드로 폴리라인을 다시 만든다. */ let planned: Vertex[] = []; /** 노드마다의 반지름·내각·법정 위반 — 서버가 함께 내려 준다(표시용). */ let nodeInfo: EditedNode[] = []; let minRadiusM = 0; /** 서버가 준 곡선 성분 — 손잡이(곡선 시작·끝점)를 그리는 재료. 편집하면 비운다. */ let curveInfo: EditedCurve[] = []; /** 꺾임점마다의 편집값 — 곡선을 둘지, 반지름을 못박을지(2026-09-07 사용자 지시). */ let curveOn: boolean[] = []; let curveRadius: Array = []; /** 붙들어 둔 값 — 무엇을 고정할지와, 길이를 고정했을 때의 그 길이(m). * 노드를 옮기면 교각이 바뀌어 R 과 길이 중 하나는 반드시 따라 움직인다(`_Edits.ts`). */ let curveLock: CurveLock[] = []; let curveArc: Array = []; /** 지금 고른 꺾임점 — 곡선 편집줄이 이 자리를 만진다. 없으면 -1. */ let picked = -1; /** 되돌리기 사진첩 — 노선을 읽은 뒤에 선다(그전에는 되돌릴 것이 없다). */ let history: RouteEditHistory | null = null; let meta: VWorldMeta | null = null; /** 바탕 등고선 한 벌 — LAS 것이거나 도엽 것. 고르기는 `_Contour` 몫. */ let contours: RouteEditContours | null = null; /** 등고선 말고 함께 깔 도엽 레이어(하천중심선). */ let otherSheets: PreparedLayer[] = []; /** 고른 등고선 가닥 — 없으면 -1(계획서 0-9 ⑦). */ let pickedContour = -1; let view: ViewState = { width: 0, height: 0, scale: 1, offsetX: 0, offsetY: 0, mapRect: computeMapRect(null, 0, 0), }; let closed = false; const close = (): void => { closed = true; window.removeEventListener("resize", resize); historyControls.dispose(); // 단축키는 창(window)에 달려 있어 안 떼면 닫힌 뒤에도 산다. curveLabelBox.destroy(); // 패널은 `document.body` 에 붙어 있어 스스로 안 사라진다. overlay.remove(); }; overlay.querySelector(".b05-routeedit__close")!.addEventListener("click", close); overlay.querySelector('[data-act="cancel"]')!.addEventListener("click", close); // 배경 클릭으로 닫지 않는다 — 고치던 노선을 실수로 날리지 않게. function resize(): void { if (closed) return; const wrap = canvas.parentElement!; const ratio = window.devicePixelRatio || 1; const width = wrap.clientWidth; const height = wrap.clientHeight; canvas.width = Math.round(width * ratio); canvas.height = Math.round(height * ratio); canvas.style.width = `${width}px`; canvas.style.height = `${height}px`; context.setTransform(ratio, 0, 0, ratio, 0, 0); view = { ...view, width, height, mapRect: computeMapRect(meta, width, height) }; draw(); } window.addEventListener("resize", resize); const toScreen = (vertex: Vertex): [number, number] => meta ? metricToScreen(meta, view, vertex[0], vertex[1]) : [0, 0]; /** 화면 px → 사업지 좌표(m). `metricToScreen`이 선형이므로 두 기준점으로 역산한다. */ function toMetric(px: number, py: number): Vertex { if (!meta) return [0, 0]; const [x0, y0] = metricToScreen(meta, view, meta.x_min, meta.y_min); const [x1, y1] = metricToScreen( meta, view, meta.x_min + meta.width_meters, meta.y_min + meta.height_meters, ); const sx = (x1 - x0) / (meta.width_meters || 1); const sy = (y1 - y0) / (meta.height_meters || 1); return [meta.x_min + (px - x0) / (sx || 1), meta.y_min + (py - y0) / (sy || 1)]; } /** 화면 1m 당 픽셀 — 측점 라벨 솎기 단계를 여기서 정한다. `metricToScreen` 이 선형이라 * 100m 떨어진 두 점으로 잰다(1m 로 재면 반올림 오차가 그대로 비율에 실린다). */ function pxPerMeter(): number { if (!meta) return 1; const [x0] = metricToScreen(meta, view, meta.x_min, meta.y_min); const [x1] = metricToScreen(meta, view, meta.x_min + 100, meta.y_min); return Math.abs(x1 - x0) / 100; } /** 지금 화면에 낼 등고선 간격(m) — 그리기와 집기가 같은 값을 보게 한 자리에서 셈한다. */ const contourStepM = (): number => contours ? pickLevelStep(contours.layer, view, contours.intervalM) : 0; function draw(): void { if (closed) return; drawRouteEditScene(context, { view, toScreen, pxPerMeter: pxPerMeter(), hasMeta: meta !== null, contours, otherSheets, pickedContour, contourStepM: contourStepM(), expected, plannedLine, planned, nodeInfo, curveInfo, curveOn, picked, stationIntervalM, }); // 라벨은 **그린 뒤** 자리를 맞춘다 — 확대·이동·창 크기가 바뀌어도 고른 노드에 붙어 있게. syncCurveBar(); } /** 잡기 — 셈은 `_RouteEdit_Input` 몫이고, 여기서는 지금 상태를 건네준다. */ const handleAt = (px: number, py: number): { node: number; end: "start" | "end" } | null => handleAtScreen( // 붙들어 둔 곡선은 손잡이로도 안 바뀐다 — 끌면 R 이 바뀌기 때문(사용자 지시 5). curveInfo.filter((entry) => (curveLock[entry.node_first] ?? null) === null), 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( node: number, which: "start" | "end", to: Vertex, ): { apex: Vertex; radius: number } | null { const curve = curveInfo.find((entry) => entry.node_first === node); if (!curve) return null; 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); } /** 노드를 고쳤다 — **곡선을 그 자리에서 다시 그린다**(2026-09-07 사용자 지적 ①②). * * 예전에는 그려 둔 선과 손잡이를 통째로 비웠다. 그러면 노드 하나만 건드려도 **곡선이 전부 * 사라진 것처럼** 보였다 — 값은 남아 있는데 화면만 「지워졌다」고 말하니 되돌릴 길을 찾게 됐다. * 지금은 서버와 **같은 규칙**(`buildEditedPolyline` 짝)으로 즉시 다시 만든다. [확인] 때 * 서버가 정본으로 다시 내는 것은 그대로다. */ function markEdited(): void { // 길이를 붙든 자리는 교각이 바뀌었을 수 있다 — 그리기 전에 R 부터 다시 잡는다. applyArcLocks(planned, curveLock, curveArc, curveRadius); const built = buildEditedPolyline(planned, curveOn, curveRadius, minRadiusM); plannedLine = built.vertices; curveInfo = built.curves; nodeInfo = built.nodes; } /** 상태줄 머리 — 지금 그려진 계획노선 길이와 노드 수(계획서 0-9 ①). 원호가 정점으로 * 펴져 있어 브라우저에서 바로 잴 수 있다 — 서버에 묻지 않는다. */ const routeHead = (): string => `길이 ${polylineLengthM(plannedLine.length ? plannedLine : planned).toFixed(1)}m · ` + `노드 ${planned.length}개`; /** 고른 등고선의 높이 — 못 읽었으면 높이 없이 「고른 등고선」만(계획서 0-9 ⑦). */ const contourHint = (): string => { if (pickedContour < 0) return "등고선을 누르면 그 줄의 높이가 보입니다."; const level = contours?.layer.features[pickedContour]?.labelValue ?? null; return level === null ? "등고선 한 줄을 골랐습니다." : `고른 등고선 ${level}m.`; }; /** 상태줄 꼬리 — 셈은 `_Edits` 몫. */ const curveHint = (): string => curveSummary({ nodeCount: planned.length, curveOn, curveRadius, curveLock, curveCount: curveInfo.length, violationCount: nodeInfo.filter((node) => node.violations.length).length, minRadiusM, fresh: nodeInfo.length === 0, }); // ── 곡선 라벨 — 고른 꺾임점 옆(곡선 중심 반대쪽)에 뜬다. 그리기는 `_Label` 몫 ── const curveLabelBox = createCurveLabel({ 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); curveArc[picked] = value; curveRadius[picked] = value !== null && deflection > 1e-9 ? value / deflection : null; applyEdit(value === null ? "반지름을 자동으로 되돌렸습니다." : "곡선 길이를 바꿨습니다."); }, onLock: (lock) => { if (picked < 0) return; curveLock[picked] = lock; const deflection = deflectionRad(nodeInfo[picked]?.inner_angle_deg); const shown = curveRadius[picked] ?? nodeInfo[picked]?.radius_m ?? null; // 길이를 붙들려면 지금 길이를 적어 둬야 한다 — 뒤에 교각이 바뀌면 이 값으로 R 을 다시 잡는다. if (lock === "arc") { curveArc[picked] = shown !== null && deflection > 1e-9 ? shown * deflection : null; } // R 을 붙들 때 칸이 비어 있으면 지금 그려진 R 을 적어 둔다(자동 상태를 그대로 못 박음). if (lock === "radius" && curveRadius[picked] === null) curveRadius[picked] = shown; applyEdit( lock === "radius" ? "반지름을 고정했습니다." : lock === "arc" ? "곡선 길이를 고정했습니다." : "고정을 풀었습니다.", ); }, onCurveOn: (on) => { if (picked < 0) return; curveOn[picked] = on; applyEdit(on ? "곡선을 넣었습니다." : "곡선을 지웠습니다."); }, }); /** 고른 자리에 맞춰 라벨을 옮겨 그린다. 끝점은 곡선이 없으므로 라벨을 숨긴다. */ function syncCurveBar(): void { if (!(picked > 0 && picked < planned.length - 1)) { curveLabelBox.hide(); return; } const pickedCurve = curveInfo.find((entry) => entry.node_first === picked); const shown = curveRadius[picked] ?? pickedCurve?.radius_m ?? null; const deflection = deflectionRad(nodeInfo[picked]?.inner_angle_deg); const rect = canvas.getBoundingClientRect(); const [screenX, screenY] = toScreen(planned[picked]); curveLabelBox.show({ seat: picked, // 패널은 `position: fixed` 라 **화면 좌표**로 넘긴다. at: [screenX + rect.left, screenY + rect.top], // 넘어가도 되는 테두리 = **지도 칸**(하단 정보행 위까지). 밖으로 나가면 지금 무엇을 // 고치는지 모달 안에서 안 보인다(2026-09-12 사용자 지적 ⑨). bounds: { left: rect.left + 8, top: rect.top + 8, right: rect.right - 8, bottom: rect.bottom - 8, }, centerDirection: pickedCurve ? centerDirectionOf( toScreen([pickedCurve.apex[0], pickedCurve.apex[1]]), toScreen(pickedCurve.start), toScreen(pickedCurve.end), ) : null, curveOn: curveOn[picked] !== false, radiusShown: shown, arcLengthShown: shown === null || deflection <= 1e-9 ? null : shown * deflection, lock: curveLock[picked] ?? null, innerAngleDeg: nodeInfo[picked]?.inner_angle_deg ?? null, }); } /** 한 번의 편집을 마무리한다 — 다시 그리고, 라벨·상태줄을 맞추고, 되돌리기에 쌓는다. */ function applyEdit(message: string, record = true): void { markEdited(); syncCurveBar(); status.textContent = `${routeHead()} — ${message} ${curveHint()}`; draw(); if (record) history?.commit(snapshotNow()); historyControls.sync(); } /** 지금 편집값을 사진 한 벌로 담는다 — 되돌리기가 쌓아 두는 것. */ function snapshotNow(): RouteEditSnapshot { return { planned, curveOn, curveRadius, curveLock, curveArc, picked }; } const historyControls = bindHistoryControls({ overlay, getHistory: () => history, restore: (snapshot, message) => { planned = snapshot.planned; curveOn = snapshot.curveOn; curveRadius = snapshot.curveRadius; curveLock = snapshot.curveLock; curveArc = snapshot.curveArc; picked = snapshot.picked; applyEdit(message, false); // 되살리는 것은 새 걸음이 아니다. }, }); // ── 조작 — 노드 끌기 / 두 번 클릭 삽입 / 오른쪽 클릭 삭제 ── // 지도 이동·확대는 `bindRouteEditNavigation`(가운데 버튼 팬 · 휠) 몫이다. let dragNode = -1; /** 끌고 있는 곡선 손잡이(시작·끝점). **고른 곡선에만** 있다. */ 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; // **노드가 손잡이보다 먼저다**(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 (contours) { // 노드도 손잡이도 아니면 **등고선**을 집는다 — 노선 편집이 늘 먼저다(계획서 0-9 ⑦). // 빈 자리를 누르면 -1 이 되어 고른 것이 풀린다. const hit = hitPreparedLayer(contours.layer, view, px, py, CONTOUR_HIT_PX, contourStepM()); if (hit !== pickedContour) { pickedContour = hit; status.textContent = `${routeHead()} — ${contourHint()} ${curveHint()}`; draw(); } } canvas.setPointerCapture(event.pointerId); }); canvas.addEventListener("pointermove", (event) => { const rect = canvas.getBoundingClientRect(); const px = event.clientX - rect.left; const py = event.clientY - rect.top; if (dragHandle) { // 곡선 시작·끝점을 끈다 — 그쪽 직선 각도와 반지름이 함께 바뀐다(2026-09-07 사용자 확정). const node = dragHandle.node; const moved = dragHandleTo(node, dragHandle.end, toMetric(px, py)); if (moved) { planned[node] = moved.apex; curveRadius[node] = Math.round(moved.radius * 100) / 100; curveOn[node] = true; picked = node; // 손잡이 자리는 다시 셈한 곡선에서 나온다 — 접선 자리가 모자라 R 이 눌리면 손이 // 끄는 자리보다 덜 따라오고, 그 눌림이 그 자리에서 눈에 보인다. dragMoved = true; markEdited(); syncCurveBar(); status.textContent = `${routeHead()} — 곡선을 잡는 중. ${curveHint()}`; draw(); } return; } if (dragNode >= 0) { dragMoved = true; planned[dragNode] = toMetric(px, py); markEdited(); // 곡선을 그 자리에서 다시 그린다 — 나머지 곡선은 그대로 남는다. // 끄는 동안에도 상태줄이 살아 있어야 한다 — 예전에는 여기서 아무 말이 없어 // 「곡선이 사라졌다」는 인상만 남았다(2026-09-07 사용자 지적 ②). status.textContent = `${routeHead()} — 옮기는 중. ${curveHint()}`; draw(); return; } 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; dragMoved = false; }; canvas.addEventListener("pointerup", endDrag); canvas.addEventListener("pointercancel", endDrag); canvas.addEventListener("dblclick", (event) => { const rect = canvas.getBoundingClientRect(); const px = event.clientX - rect.left; const py = event.clientY - rect.top; const segment = segmentAt(px, py); if (segment < 0) return; planned.splice(segment + 1, 0, toMetric(px, py)); // 편집값도 같은 자리에 끼워 넣는다 — 안 그러면 뒤 노드의 R·켬끔이 한 칸씩 밀린다. curveOn.splice(segment + 1, 0, true); curveRadius.splice(segment + 1, 0, null); curveLock.splice(segment + 1, 0, null); curveArc.splice(segment + 1, 0, null); picked = segment + 1; applyEdit("새 노드를 넣었습니다(직선 추가)."); }); canvas.addEventListener("contextmenu", (event) => { event.preventDefault(); const rect = canvas.getBoundingClientRect(); const index = nodeAt(event.clientX - rect.left, event.clientY - rect.top); if (index < 0) return; if (planned.length <= 2) { showToast("노선은 노드가 2개 이상이어야 합니다.", "error"); return; } planned.splice(index, 1); curveOn.splice(index, 1); curveRadius.splice(index, 1); curveLock.splice(index, 1); curveArc.splice(index, 1); picked = -1; applyEdit("노드를 지웠습니다(직선 삭제)."); }); // 확대·이동은 배수유역도와 같은 동작으로 — 휠은 당기면 확대, 팬은 가운데 버튼 전용. bindRouteEditNavigation({ canvas, getView: () => view, setView: (next) => { view = next; }, getMeta: () => meta, draw, }); async function runHeavy(label: string, task: () => Promise): Promise { busy.hidden = false; // ⚠ 「몇 분」은 옛 값이었다 — 0-11 로 **약 90초**가 됐다(2026-09-09 실측 네 번: // 87.3 · 90.0 · 93.9 · 95.4초). 중간 취소를 안 만드는 대신, **얼마나 지났는지**를 // 보여 사람이 멈춘 것인지 도는 것인지 알 수 있게 한다(계획서 0-2). const message = busy.querySelector("span")!; const started = Date.now(); const tick = (): void => { const seconds = Math.round((Date.now() - started) / 1000); message.textContent = `${label} — 배수유역부터 다시 계산 중입니다. 1분 반쯤 걸립니다 (${seconds}초 지남).`; }; tick(); const timer = window.setInterval(tick, 1000); try { await task(); // 노선이 바뀌면 세션 초안·조회 캐시는 옛 노선 것이라 남기지 않는다(PLAN 0-7 확정 5). clearDrafts(projectId); clearResults(projectId); showToast("노선을 다시 계산했습니다.", "success"); close(); await onApplied(); } catch (error) { busy.hidden = true; showToast(error instanceof Error ? error.message : "노선 재계산에 실패했습니다.", "error"); } finally { window.clearInterval(timer); // 성공·실패·닫힘 어느 쪽이든 멈춘다 } } overlay.querySelector('[data-act="apply"]')!.addEventListener("click", () => { if (planned.length < 2) { showToast("노선은 노드가 2개 이상이어야 합니다.", "error"); return; } void runHeavy("계획노선 반영", () => replanRoute( projectId, planned.map(([x, y], index) => ({ x, y, curve: curveOn[index] !== false, radius_m: curveRadius[index] ?? null, })), ), ); }); overlay.querySelector('[data-act="reset"]')!.addEventListener("click", () => { void runHeavy("예상노선으로 되돌리기", () => resetRoutePlan(projectId)); }); // ── 자료 읽기 — 노선 두 벌 + 등고선 도엽(배수유역도와 같은 것) ── try { const [plan, drainage] = await Promise.all([ fetchRoutePlan(projectId), fetchDrainageLayers(projectId, () => {}), ]); if (closed) return; expected = plan.expected as Vertex[]; plannedLine = (plan.planned as Vertex[]).map((vertex) => [vertex[0], vertex[1]]); // 잡는 것은 **노드**다 — 폴리라인 정점에는 원호 위 점이 섞여 있어 편집 대상이 아니다 // (2026-09-06 사용자 지시: 노드를 제어해 계획노선을 고친다). const nodes = plan.nodes ?? []; minRadiusM = plan.min_radius_m ?? 0; // 곡선 성분을 편집할 수 있는 꼴로 편다 — 셈은 `_Edits` 몫(까닭도 그쪽에 적었다). const flat = flattenServerPlan(nodes, plan.curves ?? []); planned = flat.planned; nodeInfo = flat.nodes; curveInfo = flat.curves; curveOn = flat.curveOn; curveRadius = flat.curveRadius; curveLock = flat.curveLock; curveArc = flat.curveArc; picked = -1; // 여기가 [초기화]가 돌아갈 자리다 — 창을 연 그대로. history = createRouteEditHistory(snapshotNow()); historyControls.sync(); syncCurveBar(); if (!planned.length) planned = plannedLine.map((vertex) => [vertex[0], vertex[1]]); meta = drainage.meta; const normalizer = createNormalizer(drainage.meta); // 등고선은 따로 고른다(LAS 우선). 나머지 도엽 레이어(하천중심선)만 배경으로 깐다. otherSheets = drainage.layers .filter(([layer]) => layer !== "도엽_등고선") .map(([, collection]) => (collection ? prepareLayer(collection, normalizer) : null)) .filter((layer): layer is PreparedLayer => layer !== null); contours = await loadRouteEditContours( projectId, drainage.meta, normalizer, drainage.layers.find(([layer]) => layer === "도엽_등고선")?.[1] ?? null, { surfaceModelId: options.surfaceModelId ?? null, intervalM: options.contourIntervalM ?? 1, smooth: options.smooth ?? false, }, ); if (closed) return; resize(); const xs = planned.map((vertex) => vertex[0]); const ys = planned.map((vertex) => vertex[1]); const fitted = computeRouteView( meta, { x_min: Math.min(...xs), x_max: Math.max(...xs), y_min: Math.min(...ys), y_max: Math.max(...ys), }, view.width, view.height, ); view = { ...view, ...fitted }; status.textContent = `${routeHead()} · ${plan.edited ? "고친 계획노선" : "초기 폴리라인"} · ` + `${contours?.source === "las" ? "LAS 등고선" : "도엽 등고선"} · ` + curveHint(); draw(); } catch (error) { status.textContent = error instanceof Error ? error.message : "노선을 읽지 못했습니다."; } }