/* ============================================================================= * 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 "./B05_Profile_UI_Style_RouteEdit.css"; /** 노드를 잡았다고 볼 거리(px). 손가락·마우스 모두 무리 없는 크기. */ const NODE_HIT_PX = 9; /** 노드 반지름(px). */ const NODE_R = 4; /** 끌기로 볼 최소 이동(px) — 이보다 작으면 클릭으로 본다. */ const DRAG_THRESHOLD_PX = 3; 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 planned: Vertex[] = []; 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(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; for (const vertex of planned) { const [x, y] = toScreen(vertex); context.beginPath(); context.arc(x, y, NODE_R, 0, Math.PI * 2); context.fill(); context.stroke(); } context.restore(); } /** 화면 좌표에 가장 가까운 노드. 문턱 밖이면 -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; } // ── 조작 — 노드 끌기 / 배경 끌기(팬) / 휠 확대 / 두 번 클릭 삽입 / 오른쪽 클릭 삭제 ── let dragNode = -1; 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; dragNode = nodeAt(px, py); if (dragNode < 0) 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 (dragNode >= 0) { planned[dragNode] = toMetric(px, py); 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 = nodeAt(px, py) >= 0 ? "grab" : "default"; }); const endDrag = (event: PointerEvent): void => { if (canvas.hasPointerCapture(event.pointerId)) canvas.releasePointerCapture(event.pointerId); dragNode = -1; 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)); status.textContent = `노드 ${planned.length}개 — 새 노드를 넣었습니다.`; 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); status.textContent = `노드 ${planned.length}개 — 노드를 지웠습니다.`; 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)); }); 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[]; planned = (plan.planned as Vertex[]).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 ? "고친 계획노선" : "예상노선과 같음"}`; draw(); } catch (error) { status.textContent = error instanceof Error ? error.message : "노선을 읽지 못했습니다."; } }