/* ============================================================================= * B05_Profile_UI_RouteEdit.ts * 계획노선 편집 모달 — 예상노선(점선) 위에 계획노선(실선)을 고쳐 그린다. * * 왜 모달인가(2026-09-06 사용자 확정) — [확인]을 누르면 배수유역부터 종·횡단·유토곡선까지 * 전 단계가 다시 도는 무거운 작업이다(용화 67측점 3분대). 신중히 하라는 뜻으로 큰 모달을 * 쓰고, **편집 중에는 아무 계산도 나가지 않는다**. * * 노선은 두 벌이다 — 예상노선(원본, 안 바뀜)과 계획노선(수정본, 사용자가 고침). * [초기화]는 마지막에 적용한 초기노선 갈래로 편집값만 되돌린다(계획서 20 · `_Initial`). * * 그림은 배수유역도와 같은 지도 도구(`B04_PreProcess_UI_MapRender`)를 쓴다 — 등고선 도엽은 * 위경도, 노선은 사업지 좌표계(m)지만 두 변환기가 같은 정규화 공간을 본다. * ========================================================================== */ import { computeMapRect, metricToScreen, pickLevelStep, type PreparedLayer, type ViewState, } from "../B04_PreProcess/B04_PreProcess_UI_MapRender"; import type { VWorldMeta } from "../B04_PreProcess/B04_PreProcess_Api_Fetch"; import { showToast } from "@ui/ui_template_elements"; import { bindMinorContourToggle, loadRouteEditBackground, type RouteEditContours, } from "./B05_Profile_UI_RouteEdit_Contour"; import { fetchDrainageLayers } from "./B05_Profile_UI_Drainage_Parts"; import { fetchRoutePlan } from "./B05_Profile_Api_Replan"; import { bindRouteApply } from "./B05_Profile_UI_RouteEdit_Apply"; import { buildEditedPolyline, type EditedCurve, type EditedNode, } from "./B05_Profile_UI_RouteEdit_Curve"; import { drawRouteEditScene, polylineLengthM } from "./B05_Profile_UI_RouteEdit_Render"; import { bindNodeSplice, bindRouteEditNavigation } from "./B05_Profile_UI_RouteEdit_Input"; import { bindRouteEditPointer } from "./B05_Profile_UI_RouteEdit_Pointer"; import { createCrossPreview } from "./B05_Profile_UI_RouteEdit_Cross"; import { createMapRotation } from "./B05_Profile_UI_RouteEdit_Rotate"; import { createRouteEditChrome } from "./B05_Profile_UI_RouteEdit_Chrome"; import { bindMeasureControls, createMeasureTool } from "./B05_Profile_UI_RouteEdit_Measure"; import { createSlopeLabels } from "./B05_Profile_UI_RouteEdit_Slope"; import { captureView, createViewMemory, fitCanvas, openingView, screenToMetric, viewPxPerMeter, } from "./B05_Profile_UI_RouteEdit_View"; import { createCurveBar } from "./B05_Profile_UI_RouteEdit_CurveBar"; import { lockColumn, readRouteLocks, routeLocksOf, writeRouteLocks, } from "./B05_Profile_UI_RouteEdit_Lock"; import { createInitialPanel } from "./B05_Profile_UI_RouteEdit_Initial"; import { applyArcLocks, applyCurveLimits, curveSummary, flattenServerPlan, spliceNode, 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"; /** 등고선 눈금을 고를 때 쓸 라벨 예산 — B04 지도 기본값(350)보다 **넉넉히** 둔다 * (2026-09-12 사용자 지시 ⑦ 「좀더 촘촘히」). 편집 창은 노선 둘레 300m 띠만 보이므로 * 같은 예산으로도 화면에 드는 줄이 적어 눈금이 필요 이상으로 성겼다. */ const CONTOUR_LEVEL_BUDGET = 1200; 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 chrome = createRouteEditChrome(); const { overlay, canvas, status, busy, measureBox, measureText, measureButton } = chrome; const { minorContourButton } = chrome; const context = canvas.getContext("2d")!; let expected: Vertex[] = []; /** 그려 보이는 계획노선 — 원호가 섞인 폴리라인. **잡는 대상이 아니다.** */ let plannedLine: Vertex[] = []; /** 사용자가 잡아 옮기는 **노드**(꺾임점). 서버가 이 노드로 폴리라인을 다시 만든다. */ let planned: Vertex[] = []; /** 노드마다의 반지름·내각·법정 위반 — 서버가 함께 내려 준다(표시용). */ let nodeInfo: EditedNode[] = []; let minRadiusM = 0; /** **못 넘는** 하한 — 0이면 제한 없음. 기본 반지름(`minRadiusM`)과 다른 값이다(계획서 0-9 ④). */ let limitRadiusM = 0; let limitArcM = 0; /** 서버가 준 곡선 성분 — 손잡이(곡선 시작·끝점)를 그리는 재료. 편집하면 비운다. */ let curveInfo: EditedCurve[] = []; /** 꺾임점마다의 편집값 — 곡선을 둘지, 반지름을 못박을지(2026-09-07 사용자 지시). */ let curveOn: boolean[] = []; let curveRadius: Array = []; /** 붙들어 둔 값 — 무엇을 고정할지와, 길이를 고정했을 때의 그 길이(m). * 노드를 옮기면 교각이 바뀌어 R 과 길이 중 하나는 반드시 따라 움직인다(`_Edits.ts`). */ let curveLock: CurveLock[] = []; let curveArc: Array = []; /** 자리를 못 박은 노드(2026-09-12 사용자 지시 ① · 계획서 22-5) — 0번 = 시점, 끝 칸 = 종점 * (기본 고정), 사이 = 교각점. 켜진 자리는 안 끌리고 반지름·곡선 길이 칸으로만 바뀐다. * 고정 한 벌의 저장 · 읽기는 `_Lock` 몫. */ let apexLock: boolean[] = []; /** 지금 고른 꺾임점 — 곡선 편집줄이 이 자리를 만진다. 없으면 -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; /** 측점 횡단 미리보기 창 — 측점 눈금을 누르면 뜬다(계획서 0-9 ⑧). */ const crossPreview = createCrossPreview({ projectId, side: overlay.querySelector(".b05-routeedit__side")!, request: () => ({ vertices: planned.map(([x, y], index) => ({ x, y, curve: curveOn[index] !== false, radius_m: curveRadius[index] ?? null, })), min_radius_m: minRadiusM || 12, station_interval_m: stationIntervalM, }), onShown: () => draw(), // 횡단이 뜨면 그 측점 반폭 선을 그린다. }); /** 구간 재기 — 계획·예상 노선 위 두 점. 셈·서버 묻기는 `_Measure` 몫(계획서 0-9 ⑤). */ const measure = createMeasureTool({ projectId, stationIntervalM, lines: () => ({ planned: plannedLine.length ? plannedLine : planned, expected }), toScreen: (vertex) => toScreen(vertex), isClosed: () => closed, onChange: () => { measureControls.sync(); draw(); }, }); /** 재기 단추·작은 창 — 배선은 `_Measure` 몫. */ const measureControls = bindMeasureControls({ measure, box: measureBox, text: measureText, button: measureButton, closeButton: overlay.querySelector(".b05-routeedit__measure-close")!, onShown: () => { if (picked < 0) return; picked = -1; // 둘이 같이 뜨면 어느 쪽을 만지는지 헷갈린다. syncCurveBar(); }, redraw: () => draw(), }); let view: ViewState = { width: 0, height: 0, scale: 1, offsetX: 0, offsetY: 0, mapRect: computeMapRect(null, 0, 0), }; let closed = false; /** 지도 회전 — 단추 배선과 좌표 되돌리기는 `_Rotate` 몫(계획서 0-9 ⑯). */ const rotation = createMapRotation({ overlay, size: () => view, onChange: () => { syncCurveBar(); // 떠 있는 패널도 돌아간 노드 옆으로 따라가야 한다. draw(); }, }); /** 측점 사이 기울기 라벨 — 높이 묻기·그리기는 `_Slope` 몫(계획서 18 ①). */ const slope = createSlopeLabels({ projectId, isClosed: () => closed, onChange: () => draw() }); /** 1m 간격 등고선 켬끔 — 기본 꺼짐(계획서 18 ②). */ const showMinorContours = bindMinorContourToggle(minorContourButton, () => draw()); /** 보던 화면 기억(계획서 18 ③) — 자료를 읽고 화면을 되살린 **뒤부터** 적는다. */ let viewReady = false; const viewMemory = createViewMemory(projectId, () => meta && viewReady ? captureView(meta, view, rotation.radians()) : null, ); const close = (): void => { viewMemory.flush(); closed = true; window.removeEventListener("resize", resize); historyControls.dispose(); // 단축키는 창(window)에 달려 있어 안 떼면 닫힌 뒤에도 산다. curveLabelBox.destroy(); // 패널은 `document.body` 에 붙어 있어 스스로 안 사라진다. overlay.remove(); }; /** 초기노선 갈래 — 로직 단추 · [초기화] · 비교값(계획서 20). 서버 묻기는 `_Initial` 몫. */ const initial = createInitialPanel({ overlay, projectId, planMinRadiusM: () => minRadiusM, apply: (snapshot, message) => restoreSnapshot(snapshot, message, true), }); const cancel = (): void => (initial.cancel(), close()); // 이 창에서 바꾼 초기값도 버린다. overlay.querySelector(".b05-routeedit__close")!.addEventListener("click", cancel); overlay.querySelector('[data-act="cancel"]')!.addEventListener("click", cancel); // 배경 클릭으로 닫지 않는다 — 고치던 노선을 실수로 날리지 않게. function resize(): void { if (closed) return; const { width, height } = fitCanvas(canvas, context); 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). 셈은 `_View` 몫. */ const toMetric = (px: number, py: number): Vertex => meta ? screenToMetric(meta, view, px, py) : [0, 0]; /** 화면 1m 당 픽셀 — 측점 라벨 솎기 단계를 여기서 정한다. */ const pxPerMeter = (): number => (meta ? viewPxPerMeter(meta, view) : 1); /** 지금 화면에 낼 등고선 간격(m) — 그리기와 집기가 같은 값을 보게 한 자리에서 셈한다. */ const contourStepM = (): number => contours ? pickLevelStep(contours.layer, view, contours.intervalM, CONTOUR_LEVEL_BUDGET) : 0; function draw(): void { if (closed) return; drawRouteEditScene(context, { view, toScreen, pxPerMeter: pxPerMeter(), hasMeta: meta !== null, contours, otherSheets, pickedContour, contourStepM: contourStepM(), rotationRad: rotation.radians(), uprightRad: rotation.uprightRad(), minorContours: showMinorContours() ? (contours?.minor?.() ?? null) : null, overStations: (ctx) => slope.draw(ctx, { line: plannedLine.length ? plannedLine : planned, intervalM: stationIntervalM, pxPerMeter: pxPerMeter(), toScreen, uprightRad: rotation.uprightRad(), dragging: pointer.dragging(), }), measure: measure.marks(), crossWidth: crossPreview.watched(), expected, plannedLine, planned, nodeInfo, curveInfo, curveOn, locked: apexLock, picked, stationIntervalM, }); // 라벨은 **그린 뒤** 자리를 맞춘다 — 확대·이동·창 크기가 바뀌어도 고른 노드에 붙어 있게. syncCurveBar(); viewMemory.touch(); // 회전·이동·확대 모두 다시 그리기로 끝난다 — 여기 한 곳에서 적는다. } /** 노드를 고쳤다 — **곡선을 그 자리에서 다시 그린다**(2026-09-07 사용자 지적 ①②). * * 예전에는 그려 둔 선과 손잡이를 통째로 비웠다. 그러면 노드 하나만 건드려도 **곡선이 전부 * 사라진 것처럼** 보였다 — 값은 남아 있는데 화면만 「지워졌다」고 말하니 되돌릴 길을 찾게 됐다. * 지금은 서버와 **같은 규칙**(`buildEditedPolyline` 짝)으로 즉시 다시 만든다. [확인] 때 * 서버가 정본으로 다시 내는 것은 그대로다. */ function markEdited(): void { // 길이를 붙든 자리는 교각이 바뀌었을 수 있다 — 그리기 전에 R 부터 다시 잡는다. applyArcLocks(planned, curveLock, curveArc, curveRadius); // 지정해 둔 값이 하한을 밑돌면 하한까지 끌어올린다(계획서 0-9 ④). // 하한은 **R 과 L 을 함께 본 값**이다 — 내각 155° 이상은 R 만 본다(2026-09-12 확정). applyCurveLimits(planned, curveOn, curveRadius, limitRadiusM, limitArcM); const built = buildEditedPolyline(planned, curveOn, curveRadius, minRadiusM); plannedLine = built.vertices; curveInfo = built.curves; nodeInfo = built.nodes; } /** 상태줄 머리 — 지금 그려진 계획노선 길이와 노드 수(계획서 0-9 ①). 원호가 정점으로 * 펴져 있어 브라우저에서 바로 잴 수 있다 — 서버에 묻지 않는다. */ const routeHead = (): string => `예상노선 ${polylineLengthM(expected).toFixed(1)}m · ` + `계획노선 ${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, apexLock: apexLock.slice(1, -1), // 시점 · 종점은 기본 고정이라 「고정 N곳」에 안 센다. curveCount: curveInfo.length, violationCount: nodeInfo.filter((node) => node.violations.length).length, minRadiusM, fresh: nodeInfo.length === 0, }); // ── 곡선 라벨 — 고른 꺾임점 옆에 뜨는 조작 패널. 배선은 `_CurveBar` 몫 ── const curveBar = createCurveBar({ canvas, state: () => ({ picked, planned, nodeInfo, curveInfo, curveOn, curveRadius, curveLock, curveArc, apexLock, limitRadiusM, limitArcM, }), toScreen: (vertex) => rotation.rerotate(...toScreen(vertex)), applyEdit: (message) => applyEdit(message), onUnselect: () => { picked = -1; syncCurveBar(); draw(); }, }); const curveLabelBox = curveBar.label; const syncCurveBar = curveBar.sync; /** 한 번의 편집을 마무리한다 — 다시 그리고, 라벨·상태줄을 맞추고, 되돌리기에 쌓는다. */ function applyEdit(message: string, record = true): void { markEdited(); writeRouteLocks(projectId, planned, apexLock); // 고정은 [취소] · 다시 열기에도 남는다(22-5). syncCurveBar(); status.textContent = `${routeHead()} — ${message} ${curveHint()}`; draw(); if (record) history?.commit(snapshotNow()); historyControls.sync(); } /** 지금 편집값을 사진 한 벌로 담는다 — 되돌리기가 쌓아 두는 것. */ function snapshotNow(): RouteEditSnapshot { return { planned, curveOn, curveRadius, curveLock, curveArc, apexLock, picked, rec: initial.tag(), }; } /** 사진 한 벌을 편집값으로 되살린다. 되돌리기는 새 걸음이 아니고, 초기값 싣기는 새 걸음이다. */ function restoreSnapshot(snapshot: RouteEditSnapshot, message: string, record: boolean): void { // 초기노선 싣기는 새 노선이다 — 고정은 좌표로 다시 짚는다. 되돌리기는 그 사진의 고정 그대로. const locks = record ? routeLocksOf(planned, apexLock) : null; ({ planned, curveOn, curveRadius, curveLock, curveArc, apexLock, picked } = snapshot); if (locks) apexLock = lockColumn(planned, locks); applyEdit(message, record); } const historyControls = bindHistoryControls({ overlay, getHistory: () => history, restore: (snapshot, message) => { initial.restoreTag(snapshot.rec); // 「현재 추천」도 그 걸음 것으로 — 노선과 짝. restoreSnapshot(snapshot, message, false); }, }); // ── 조작 — 노드 끌기 · 집기는 `_Pointer` 몫 / 두 번 클릭 삽입 / 오른쪽 클릭 삭제 ── // 지도 이동·확대는 `bindRouteEditNavigation`(가운데 버튼 팬 · 휠) 몫이다. const pointer = bindRouteEditPointer({ canvas, status, stationIntervalM, state: () => ({ planned, plannedLine, nodeInfo, curveInfo, curveOn, curveRadius, curveLock, apexLock, limitRadiusM, limitArcM, picked, pickedContour, contours, view, }), setPicked: (index) => { picked = index; }, setPickedContour: (index) => { pickedContour = index; }, toScreen, toMetric, unrotate: rotation.unrotate, contourStepM, measure, measureMode: () => measureControls.mode(), crossPreview, slopeRefresh: () => slope.refresh(), markEdited, syncCurveBar: () => syncCurveBar(), draw, routeHead, curveHint, contourHint, commit: () => { history?.commit(snapshotNow()); historyControls.sync(); }, }); bindNodeSplice({ canvas, unrotate: rotation.unrotate, segmentAt: pointer.segmentAt, nodeAt: pointer.nodeAt, insert: (segment, px, py) => { spliceNode(snapshotNow(), segment + 1, toMetric(px, py)); // 편집값도 같은 자리에 끼운다. picked = segment + 1; applyEdit("새 노드를 넣었습니다(직선 추가)."); }, remove: (index) => { if (planned.length <= 2) return showToast("노선은 노드가 2개 이상이어야 합니다.", "error"); if (apexLock[index]) return showToast("고정한 점은 못 지웁니다 — 고정을 먼저 푸세요.", "error"); spliceNode(snapshotNow(), index); picked = -1; applyEdit("노드를 지웠습니다(직선 삭제)."); }, }); // 확대·이동은 배수유역도와 같은 동작으로 — 휠은 당기면 확대, 팬은 가운데 버튼 전용. bindRouteEditNavigation({ canvas, getView: () => view, setView: (next) => { view = next; }, getMeta: () => meta, // 돌린 지도에서는 손이 민 방향과 그림이 움직일 방향이 다르다 — 거꾸로 돌려 넘긴다. unrotateDelta: rotation.unrotateDelta, draw, }); bindRouteApply({ overlay, busy, projectId, nodes: () => ({ planned, curveOn, curveRadius }), changed: () => history?.isDirty() ?? false, initial, close, onApplied, }); // ── 자료 읽기 — 노선 두 벌 + 등고선 도엽(배수유역도와 같은 것) ── 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; limitRadiusM = plan.limit_radius_m ?? 0; limitArcM = plan.limit_curve_length_m ?? 0; // 곡선 성분을 편집할 수 있는 꼴로 편다 — 셈은 `_Edits` 몫(까닭도 그쪽에 적었다). const flat = flattenServerPlan(nodes, plan.curves ?? []); ({ planned, curveOn, curveRadius, curveLock, curveArc } = flat); apexLock = lockColumn(planned, readRouteLocks(projectId)); // 닫기 전 고정을 좌표로 되살린다. ({ nodes: nodeInfo, curves: curveInfo } = flat); picked = -1; // 여기가 [연 상태로]가 돌아갈 자리다 — 창을 연 그대로. initial.ready(snapshotNow(), plan.edited, plan.current_initial ?? null); history = createRouteEditHistory(snapshotNow()); historyControls.sync(); syncCurveBar(); if (!planned.length) planned = plannedLine.map((vertex) => [vertex[0], vertex[1]]); meta = drainage.meta; // 등고선은 따로 고른다(LAS 우선). 나머지 도엽 레이어(하천중심선)만 배경으로 깐다. const background = await loadRouteEditBackground(projectId, drainage, { surfaceModelId: options.surfaceModelId ?? null, intervalM: options.contourIntervalM ?? 1, smooth: options.smooth ?? false, }); otherSheets = background.otherSheets; contours = background.contours; if (closed) return; // 1m 선이 있는 자료(LAS)일 때만 단추를 연다 — 도엽에는 1m 선이 없다. minorContourButton.disabled = !contours?.minor; if (!contours?.minor) minorContourButton.title = "LAS 등고선이 없어 1m 선이 없습니다"; resize(); // 보던 화면이 있으면 그대로 연다(계획서 18 ③). 없으면 노선 전체에 맞춘다. const opening = openingView(projectId, meta, view, planned); view = opening.view; rotation.set(opening.rotationRad); viewReady = true; status.textContent = `${routeHead()} · ${plan.edited ? "고친 계획노선" : "초기 폴리라인"} · ` + `${contours?.source === "las" ? "LAS 등고선" : "도엽 등고선"} · ` + curveHint(); draw(); } catch (error) { status.textContent = error instanceof Error ? error.message : "노선을 읽지 못했습니다."; } }