/* ============================================================================= * B05_Profile_UI_RouteEdit.ts * 계획노선 편집 모달 — 예상노선(점선) 위에 계획노선(실선)을 고쳐 그린다. * * 왜 모달인가(2026-09-06 사용자 확정) — [확인]을 누르면 배수유역부터 종·횡단·유토곡선까지 * 전 단계가 다시 도는 무거운 작업이다(용화 67측점 3분대). 신중히 하라는 뜻으로 큰 모달을 * 쓰고, **편집 중에는 아무 계산도 나가지 않는다**. * * 노선은 두 벌이다 — 예상노선(원본, 안 바뀜)과 계획노선(수정본, 사용자가 고침). * [예상노선으로]는 수정본을 버리고 원본으로 되돌린다(서버가 파일을 지우고 같은 재계산). * * 그림은 배수유역도와 같은 지도 도구(`B04_PreProcess_UI_MapRender`)를 쓴다 — 등고선 도엽은 * 위경도, 노선은 사업지 좌표계(m)지만 두 변환기가 같은 정규화 공간을 본다. * ========================================================================== */ import { computeMapRect, computeRouteView, drawPreparedLayer, createNormalizer, metricToScreen, prepareLayer, type PreparedLayer, type ViewState, } from "../B04_PreProcess/B04_PreProcess_UI_MapRender"; 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 { 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 "./B05_Profile_UI_Style_RouteEdit.css"; /** 노드를 잡았다고 볼 거리(px). 손가락·마우스 모두 무리 없는 크기. */ const NODE_HIT_PX = 9; /** 노드 반지름(px). */ const NODE_R = 4; /** 끌기로 볼 최소 이동(px) — 이보다 작으면 클릭으로 본다. */ const DRAG_THRESHOLD_PX = 3; /** 곡선 시작·끝점 손잡이 크기(px) — 노드 동그라미와 구별되게 **속 빈 네모**로 그린다. * 처음엔 3.5px 였는데 선과 색이 같아 눈에도 안 띄고 집기도 어려웠다(2026-09-07 실화면). */ const CURVE_HANDLE_PX = 5; type Vertex = [number, number]; /** 모달을 연다. [확인]·[예상노선으로]가 끝나면 `onApplied`를 부른다(화면 다시 읽기). */ export async function openRouteEditModal( projectId: string, onApplied: () => void | Promise, ): Promise { 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: Array<{ radius_m: number | null; inner_angle_deg: number | null; violations: string[]; }> = []; let minRadiusM = 0; /** 서버가 준 곡선 성분 — 손잡이(곡선 시작·끝점)를 그리는 재료. 편집하면 비운다. */ let curveInfo: RoutePlanCurve[] = []; /** 꺾임점마다의 편집값 — 곡선을 둘지, 반지름을 못박을지(2026-09-07 사용자 지시). */ let curveOn: boolean[] = []; let curveRadius: Array = []; /** 지금 고른 꺾임점 — 곡선 편집줄이 이 자리를 만진다. 없으면 -1. */ let picked = -1; let meta: VWorldMeta | null = null; let sheets: PreparedLayer[] = []; 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); 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)]; } function strokePolyline(points: Vertex[], dash: number[], color: string, width: number): void { if (points.length < 2) return; context.save(); context.setLineDash(dash); context.strokeStyle = color; context.lineWidth = width; context.beginPath(); points.forEach((vertex, index) => { const [x, y] = toScreen(vertex); if (index === 0) context.moveTo(x, y); else context.lineTo(x, y); }); context.stroke(); context.restore(); } function draw(): void { if (closed) return; const style = getComputedStyle(document.documentElement); context.clearRect(0, 0, view.width, view.height); context.fillStyle = style.getPropertyValue("--color-surface") || "#111"; context.fillRect(0, 0, view.width, view.height); context.save(); context.strokeStyle = style.getPropertyValue("--map-sheet-contour") || "#a5b4fc"; context.lineWidth = 0.8; for (const layer of sheets) drawPreparedLayer(context, layer, view, "dot"); context.restore(); strokePolyline( expected, [6, 5], style.getPropertyValue("--color-text-secondary") || "#9ca3af", 1.6, ); // 선은 **폴리라인**(원호 포함)을 그리고, 잡는 동그라미는 **노드**에만 찍는다. // 노드를 옮기는 동안에는 폴리라인이 없으므로 노드를 곧바로 이어 미리 보인다. strokePolyline( plannedLine.length ? plannedLine : planned, [], style.getPropertyValue("--map-route") || "#f97316", 2.4, ); context.save(); context.fillStyle = style.getPropertyValue("--map-route") || "#f97316"; context.strokeStyle = style.getPropertyValue("--map-halo") || "rgba(255,255,255,0.9)"; context.lineWidth = 1; planned.forEach((vertex, index) => { const [x, y] = toScreen(vertex); // 법정 기준을 못 맞춘 자리는 붉게 — 막지는 않고 보이기만 한다(2026-09-06 사용자 확정). const bad = (nodeInfo[index]?.violations?.length ?? 0) > 0; context.fillStyle = bad ? style.getPropertyValue("--color-danger") || "#dc2626" : style.getPropertyValue("--map-route") || "#f97316"; context.beginPath(); context.arc(x, y, index === picked ? NODE_R + 2 : NODE_R, 0, Math.PI * 2); context.fill(); context.stroke(); // 곡선을 지운 자리는 가운데를 비워 「여기는 곡선이 없다」를 보인다. if (curveOn.length && !curveOn[index] && index > 0 && index < planned.length - 1) { context.save(); context.fillStyle = style.getPropertyValue("--map-halo") || "rgba(255,255,255,0.9)"; context.beginPath(); context.arc(x, y, NODE_R - 2, 0, Math.PI * 2); context.fill(); context.restore(); } }); // 곡선 시작·끝점 — 잡아서 직선 각도와 R 을 함께 바꾸는 손잡이(2026-09-07 사용자 지시). // **속을 비우고 테두리를 굵게** 그린다 — 선·노드와 색이 같으면 눈에도 안 띄고 집기도 어렵다. context.lineWidth = 2; curveInfo.forEach((curve) => { const on = curveOn[curve.node_first] !== false; if (!on) return; // 곡선을 지운 자리에는 손잡이도 없다. [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)"; context.fill(); context.strokeStyle = style.getPropertyValue("--map-route") || "#f97316"; context.stroke(); }); }); context.restore(); } /** 화면 좌표에 가장 가까운 **곡선 손잡이**(시작·끝점). 없으면 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_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); } /** 화면 좌표에 가장 가까운 노드. 문턱 밖이면 -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 사용자 지적 ①②). * * 예전에는 그려 둔 선과 손잡이를 통째로 비웠다. 그러면 노드 하나만 건드려도 **곡선이 전부 * 사라진 것처럼** 보였다 — 값은 남아 있는데 화면만 「지워졌다」고 말하니 되돌릴 길을 찾게 됐다. * 지금은 서버와 **같은 규칙**(`buildEditedPolyline` 짝)으로 즉시 다시 만든다. [확인] 때 * 서버가 정본으로 다시 내는 것은 그대로다. */ function markEdited(): void { const built = buildEditedPolyline(planned, curveOn, curveRadius, minRadiusM); plannedLine = built.vertices; curveInfo = built.curves; nodeInfo = built.nodes; } /** 상태줄 꼬리 — 곡선 기준과 위반 수를 알린다. */ function curveHint(): string { const off = curveOn.filter( (on, index) => !on && index > 0 && index < planned.length - 1, ).length; const forced = curveRadius.filter((value) => value !== null).length; const edits = [off ? `곡선 지움 ${off}곳` : "", forced ? `R 지정 ${forced}곳` : ""] .filter(Boolean) .join(" · "); if (!nodeInfo.length) { const base = minRadiusM ? `곡선 기준 R ${minRadiusM}m — [확인] 때 반영` : ""; return edits ? `${base}${base ? " · " : ""}${edits}` : base; } const bad = nodeInfo.filter((node) => node.violations.length).length; const curves = curveInfo.length || nodeInfo.filter((node) => node.radius_m !== null).length; return ( `곡선 ${curves}곳(하한 R ${minRadiusM}m)` + `${bad ? ` · 기준 미달 ${bad}곳` : ""}${edits ? ` · ${edits}` : ""}` ); } // ── 곡선 편집줄 — 고른 자리의 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"]')!; /** 고른 자리에 맞춰 편집줄을 다시 그린다. 끝점은 곡선이 없으므로 줄을 숨긴다. */ 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; 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` : "곡선 없음 — 직선이 그대로 꺾입니다"; } curveRadiusInput.addEventListener("change", () => { if (picked < 0) return; const value = Number(curveRadiusInput.value); curveRadius[picked] = Number.isFinite(value) && value > 0 ? value : null; // 반지름만 바꾼 것이라 노드 자리는 그대로지만, 그려진 선은 낡았다. markEdited(); syncCurveBar(); status.textContent = `노드 ${planned.length}개 — 반지름을 바꿨습니다. ${curveHint()}`; draw(); }); 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(); }); // ── 조작 — 노드 끌기 / 배경 끌기(팬) / 휠 확대 / 두 번 클릭 삽입 / 오른쪽 클릭 삭제 ── let dragNode = -1; /** 끌고 있는 곡선 손잡이(시작·끝점). 노드 끌기보다 우선한다. */ let dragHandle: { node: number; end: "start" | "end" } | null = null; let panFrom: { x: number; y: number; offsetX: number; offsetY: number } | null = null; 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 = 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); }); 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 이 눌리면 손이 // 끄는 자리보다 덜 따라오고, 그 눌림이 그 자리에서 눈에 보인다. markEdited(); syncCurveBar(); status.textContent = `노드 ${planned.length}개 — 곡선을 잡는 중. ${curveHint()}`; draw(); } return; } if (dragNode >= 0) { planned[dragNode] = toMetric(px, py); 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"; }); const endDrag = (event: PointerEvent): void => { if (canvas.hasPointerCapture(event.pointerId)) canvas.releasePointerCapture(event.pointerId); dragNode = -1; dragHandle = null; panFrom = null; }; 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); picked = segment + 1; markEdited(); syncCurveBar(); status.textContent = `노드 ${planned.length}개 — 새 노드를 넣었습니다(직선 추가). ${curveHint()}`; draw(); }); 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); picked = -1; markEdited(); syncCurveBar(); status.textContent = `노드 ${planned.length}개 — 노드를 지웠습니다(직선 삭제). ${curveHint()}`; draw(); }); 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(); }, { passive: false }, ); async function runHeavy(label: string, task: () => Promise): Promise { busy.hidden = false; busy.querySelector("span")!.textContent = `${label} — 배수유역부터 다시 계산 중입니다. 몇 분 걸립니다.`; 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"); } } 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; // 곡선 성분을 **편집할 수 있는 꼴로 펴 둔다**(2026-09-07). // // 서버는 처음 만들 때 이어진 꺾임을 한 곡선으로 묶는다 — 그 곡선의 교각점은 앞뒤 직선을 // 늘려 만나는 자리라 **원본 꺾임점 중 어느 것도 아니다**. 그런데 편집은 「꺾임점 하나 = // 곡선 하나」로 표현되므로, 묶인 곡선을 그대로 두면 한 번만 손대도 그 묶음이 낱개로 // 흩어지고 **맞춰 둔 반지름이 전부 법정 하한으로 되돌아간다**(실측: R 12~199m → 전부 12m). // // 그래서 **묶인 구간을 그 교각점 하나로 갈아 끼운다** — 안쪽 꺾임점은 그 곡선이 대신하므로 // 뺀다. 앞뒤 직선과 반지름이 그대로라 **그려지는 선은 똑같고**, 이제 손대도 안 흩어진다. const curves = plan.curves ?? []; const replaced = new Map(); const dropped = new Set(); curves.forEach((curve) => { replaced.set(curve.node_first, curve); for (let index = curve.node_first + 1; index <= curve.node_last; index += 1) { dropped.add(index); } }); planned = []; nodeInfo = []; curveOn = []; curveRadius = []; curveInfo = []; nodes.forEach((node, index) => { if (dropped.has(index)) return; const curve = replaced.get(index); const at: Vertex = curve ? [curve.apex[0], curve.apex[1]] : [node.x, node.y]; const seat = planned.length; planned.push(at); nodeInfo.push({ radius_m: curve ? curve.radius_m : node.radius_m, inner_angle_deg: curve ? curve.inner_angle_deg : node.inner_angle_deg, violations: curve ? (curve.violations ?? []) : (node.violations ?? []), }); // 서버가 **곡선을 안 둔 자리는 「곡선 없음」으로 연다**(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; syncCurveBar(); if (!planned.length) planned = plannedLine.map((vertex) => [vertex[0], vertex[1]]); meta = drainage.meta; const normalizer = createNormalizer(drainage.meta); sheets = drainage.layers .map(([, collection]) => (collection ? prepareLayer(collection, normalizer) : null)) .filter((layer): layer is PreparedLayer => layer !== null); 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 = `노드 ${planned.length}개 · ${plan.edited ? "고친 계획노선" : "초기 폴리라인"} · ` + curveHint(); draw(); } catch (error) { status.textContent = error instanceof Error ? error.message : "노선을 읽지 못했습니다."; } }