diff --git a/B05_Profile/B05_Profile_Api_Replan.ts b/B05_Profile/B05_Profile_Api_Replan.ts index 5581a8e1..ad414804 100644 --- a/B05_Profile/B05_Profile_Api_Replan.ts +++ b/B05_Profile/B05_Profile_Api_Replan.ts @@ -148,6 +148,44 @@ export async function fetchRouteElevations( return payload.z; } +/** 횡단 미리보기 한 장 — 고치던 노선 그대로 그 측점만 서버가 셈해 준다(계획서 0-9 ⑧). */ +export interface CrossPreviewResponse { + status: string; + chainage_m: number; + label: string | null; + uphill_side: string | null; + plan_radius_m: number | null; + curve_widening_m: number | null; + /** 원지반 횡단 샘플. */ + samples: Array<{ offset_m?: number; elevation_m?: number | null; valid: boolean }>; + /** 기본 계획 횡단 — B06 `compute_cross_design` 이 낸 것. 계획고를 못 세우면 null. */ + design: { + design_line: Array<{ offset_m: number; elevation_m: number }>; + cut_area_m2: number; + fill_area_m2: number; + [key: string]: unknown; + } | null; +} + +export interface CrossPreviewRequest { + vertices: Array<{ x: number; y: number; curve: boolean; radius_m: number | null }>; + chainage_m: number; + min_radius_m: number; + station_interval_m: number; +} + +/** 한 측점 횡단을 묻는다. 종·횡단을 한 번 돌리므로 **한두 초** 걸린다(사용자 확정: 괜찮음). */ +export async function fetchCrossPreview( + projectId: string, + request: CrossPreviewRequest, +): Promise { + return requestJson( + `/projects/${projectId}/route/cross-preview`, + { method: "POST", body: JSON.stringify(request) }, + 120000, + ); +} + /** 계획노선을 예상노선으로 되돌리고 같은 재계산을 돈다(노선 초기화). */ export async function resetRoutePlan(projectId: string): Promise { return requestJson( diff --git a/B05_Profile/B05_Profile_Router_Terrain.py b/B05_Profile/B05_Profile_Router_Terrain.py index 31e1b353..db032f93 100644 --- a/B05_Profile/B05_Profile_Router_Terrain.py +++ b/B05_Profile/B05_Profile_Router_Terrain.py @@ -8,7 +8,8 @@ 표고 조회는 종·횡단 생성기가 쓰는 것과 **같은 sampler**(`build_surface_sampler`)를 연다. 두 화면이 다른 표고를 보면 같은 자리의 기울기가 갈린다. - POST /api/projects/{id}/route/elevations → 점 묶음의 지반고 + POST /api/projects/{id}/route/elevations → 점 묶음의 지반고 + POST /api/projects/{id}/route/cross-preview → 고치던 노선의 한 측점 횡단 미리보기 """ import asyncio @@ -22,6 +23,12 @@ from fastapi.responses import JSONResponse from pydantic import BaseModel, Field from B03_FileInput.B03_FileInput_Repository import get_project_storage_relative_path +from B05_Profile.B05_Profile_Engine_Sections_Core import ( + SectionGenerationOptions, + generate_sections, +) +from B06_Section.B06_Section_Engine_Design import compute_cross_design, curve_widening_args +from common_util.common_util_route_polyline import build_planned_polyline from common_util.common_util_storage import resolve_stored_project_path from common_util.common_util_surface_confirmation import get_surface_confirmation_params from common_util.common_util_surface_sampler import build_surface_sampler @@ -92,3 +99,123 @@ async def read_route_elevations(project_id: UUID, request: ElevationRequest) -> "z": [None if not ok else round(float(value), 3) for value, ok in zip(z, valid)], "valid": [bool(ok) for ok in valid], } + + +class PreviewVertex(BaseModel): + """편집 중인 꺾임점 하나 — `RouteVertexInput` 과 같은 꼴.""" + + x: float + y: float + curve: bool = True + radius_m: float | None = None + + +class CrossPreviewRequest(BaseModel): + """고치던 노선 그대로 한 측점의 횡단을 미리 본다.""" + + vertices: list[PreviewVertex] = Field(..., min_length=2) + chainage_m: float = Field(..., ge=0) + #: 법정 최소곡선반지름(m) — 화면이 `/route/plan` 에서 받은 값을 그대로 돌려준다. + min_radius_m: float = Field(12.0, gt=0) + station_interval_m: float | None = None + + +def _cross_preview( + project_root: Path, + params: dict, + request: CrossPreviewRequest, +) -> dict | None: + """고치던 노선으로 종·횡단을 한 번 돌려 그 측점 한 장을 뽑는다. + + **B05·B06 의 정본 로직을 그대로 재사용한다**(2026-09-12 사용자 확정 「기본 로직은 B06에 + 존재함. 재사용」) — `generate_sections` 가 측점·접선·지반 샘플을, `compute_cross_design` + 이 설계선을 만든다. 여기서 기하를 새로 짜지 않는다. + + ⚠ **계획고는 아직 없다.** 계획고는 [확인] 뒤 전 체인이 낳는 값이라 편집 중에는 존재하지 + 않는다. 그래서 그 측점의 **지반고를 그대로 계획고로 놓는다**(지반 추종) — 절·성토가 사면 + 기울기만으로 서는 「기본 계획 횡단」이며, 사용자가 보기로 한 것도 그것이다. + """ + try: + sampler = build_surface_sampler( + project_root / _MODELS_SUBDIR, + str(params["source_filter"]), + str(params["method"]), + bool(params["smooth"]), + ) + except (FileNotFoundError, KeyError, OSError, ValueError) as exc: + logger.warning("횡단 미리보기: 지표면을 열지 못했습니다 — %s", exc) + return None + + built = build_planned_polyline( + [(vertex.x, vertex.y) for vertex in request.vertices], + min_radius_m=request.min_radius_m, + # 화면이 준 노드는 이미 꺾임점이다 — 다시 뽑으면 선이 깎인다(`_write_planned_polyline`). + simplify=False, + curve_flags=[vertex.curve for vertex in request.vertices], + radii=[vertex.radius_m for vertex in request.vertices], + ) + interval = request.station_interval_m + options = ( + SectionGenerationOptions(station_interval_m=float(interval)) + if interval and interval > 0 + else SectionGenerationOptions() + ) + result = generate_sections(built.vertices, sampler, options) + sections = result["cross_sections"] + if not sections: + return None + section = min(sections, key=lambda row: abs(float(row["chainage_m"]) - request.chainage_m)) + + design = None + center_z = section.get("center_z") + if center_z is not None: + # 단면유형 기본값은 B06 화면과 같다 — 등고가 높은 쪽을 절토로 본다. + section_mode = "right_cut" if section.get("uphill_side") == "right" else "left_cut" + design = compute_cross_design( + section["samples"], + float(center_z), + ground_type="soil", + section_mode=section_mode, + **curve_widening_args(section), + ) + return { + "chainage_m": round(float(section["chainage_m"]), 3), + "label": section.get("label"), + "uphill_side": section.get("uphill_side"), + "plan_radius_m": section.get("plan_radius_m"), + "curve_widening_m": section.get("curve_widening_m"), + "samples": section["samples"], + "design": design, + "total_length_m": round(float(result["longitudinal"]["total_length_m"]), 3) + if result.get("longitudinal", {}).get("total_length_m") is not None + else None, + } + + +@router.post("/{project_id}/route/cross-preview", response_model=None) +async def read_cross_preview(project_id: UUID, request: CrossPreviewRequest) -> dict | JSONResponse: + """고치던 계획노선의 **한 측점 횡단**을 돌려준다(계획서 0-9 ⑧). + + 정본을 건드리지 않는다 — 파일도 DB 도 쓰지 않고 그 자리에서 셈해 돌려주기만 한다. + """ + pool = get_db_pool() + async with pool.acquire() as connection: + stored = await get_project_storage_relative_path(connection, project_id) + if not stored: + return JSONResponse( + status_code=404, + content={"status": "error", "message": "프로젝트 저장 경로를 찾을 수 없습니다."}, + ) + params = await get_surface_confirmation_params(connection, str(project_id)) + + project_root = Path(resolve_stored_project_path(stored)) + preview = await asyncio.to_thread(_cross_preview, project_root, params, request) + if preview is None: + return JSONResponse( + status_code=409, + content={ + "status": "error", + "message": "확정된 지표면이 없어 횡단을 미리 볼 수 없습니다.", + }, + ) + return {"status": "success", "project_id": str(project_id), **preview} diff --git a/B05_Profile/B05_Profile_UI_RouteEdit.ts b/B05_Profile/B05_Profile_UI_RouteEdit.ts index 57322c7b..99c76a89 100644 --- a/B05_Profile/B05_Profile_UI_RouteEdit.ts +++ b/B05_Profile/B05_Profile_UI_RouteEdit.ts @@ -42,13 +42,11 @@ import { handleAtScreen, nodeAtScreen, segmentAtScreen, + stationAtScreen, } from "./B05_Profile_UI_RouteEdit_Input"; +import { createCrossPreview } from "./B05_Profile_UI_RouteEdit_Cross"; import { createMeasureTool } from "./B05_Profile_UI_RouteEdit_Measure"; -import { - centerDirectionOf, - createCurveLabel, - deflectionRad, -} from "./B05_Profile_UI_RouteEdit_Label"; +import { createCurveBar } from "./B05_Profile_UI_RouteEdit_CurveBar"; import { applyArcLocks, applyCurveLimits, @@ -72,6 +70,8 @@ const NODE_HIT_PX = 9; const SEGMENT_HIT_PX = 12; /** 등고선을 집었다고 볼 거리(px) — 노드·손잡이보다 **좁게** 둔다(노선 편집이 먼저). */ const CONTOUR_HIT_PX = 6; +/** 측점 눈금을 집었다고 볼 거리(px) — 눈금이 보이는 자리를 누르면 잡히게 넉넉히. */ +const STATION_HIT_PX = 11; type Vertex = [number, number]; @@ -100,7 +100,8 @@ export async function openRouteEditModal( 계획노선 편집 노드 끌기 = 옮기기 · 노드 클릭 = R 라벨 · 선 두 번 클릭 = 노드 추가 · - 노드 오른쪽 클릭 = 삭제 · Shift+클릭 = 두 점 사이 거리·기울기 · + 노드 오른쪽 클릭 = 삭제 · 측점 눈금 클릭 = 횡단 미리보기 · + Shift+클릭 = 두 점 사이 거리·기울기 · 가운데(휠) 버튼 끌기 = 지도 이동 · 휠 = 확대 @@ -162,6 +163,21 @@ export async function openRouteEditModal( let otherSheets: PreparedLayer[] = []; /** 고른 등고선 가닥 — 없으면 -1(계획서 0-9 ⑦). */ let pickedContour = -1; + /** 측점 횡단 미리보기 창 — 측점 눈금을 누르면 뜬다(계획서 0-9 ⑧). */ + const crossPreview = createCrossPreview({ + projectId, + bounds: () => canvas.getBoundingClientRect(), + 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, + }), + }); /** 구간 재기 — Shift+클릭으로 두 점을 찍는다. 셈·서버 묻기는 `_Measure` 몫(계획서 0-9 ⑤). */ const measure = createMeasureTool({ projectId, @@ -190,6 +206,7 @@ export async function openRouteEditModal( window.removeEventListener("resize", resize); historyControls.dispose(); // 단축키는 창(window)에 달려 있어 안 떼면 닫힌 뒤에도 산다. curveLabelBox.destroy(); // 패널은 `document.body` 에 붙어 있어 스스로 안 사라진다. + crossPreview.destroy(); overlay.remove(); }; overlay.querySelector(".b05-routeedit__close")!.addEventListener("click", close); @@ -340,88 +357,26 @@ export async function openRouteEditModal( 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, + // ── 곡선 라벨 — 고른 꺾임점 옆에 뜨는 조작 패널. 배선은 `_CurveBar` 몫 ── + const curveBar = createCurveBar({ + canvas, + state: () => ({ + picked, + planned, + nodeInfo, + curveInfo, + curveOn, + curveRadius, + curveLock, + curveArc, limitRadiusM, limitArcM, - }); - } + }), + toScreen: (vertex) => toScreen(vertex), + applyEdit: (message) => applyEdit(message), + }); + const curveLabelBox = curveBar.label; + const syncCurveBar = curveBar.sync; /** 한 번의 편집을 마무리한다 — 다시 그리고, 라벨·상태줄을 맞추고, 되돌리기에 쌓는다. */ function applyEdit(message: string, record = true): void { @@ -485,6 +440,23 @@ export async function openRouteEditModal( picked = dragHandle.node; syncCurveBar(); draw(); + } else if ( + // 측점 눈금을 누르면 그 측점 횡단을 따로 띄운다(계획서 0-9 ⑧). 노드·손잡이 다음이다. + (() => { + const chainage = stationAtScreen( + plannedLine.length ? plannedLine : planned, + toScreen, + stationIntervalM, + px, + py, + STATION_HIT_PX, + ); + if (chainage === null) return false; + void crossPreview.open(chainage); + return true; + })() + ) { + /* 횡단 창이 떴다 — 더 집지 않는다. */ } else if (contours) { // 노드도 손잡이도 아니면 **등고선**을 집는다 — 노선 편집이 늘 먼저다(계획서 0-9 ⑦). // 빈 자리를 누르면 -1 이 되어 고른 것이 풀린다. diff --git a/B05_Profile/B05_Profile_UI_RouteEdit_Cross.ts b/B05_Profile/B05_Profile_UI_RouteEdit_Cross.ts new file mode 100644 index 00000000..7d8b47ad --- /dev/null +++ b/B05_Profile/B05_Profile_UI_RouteEdit_Cross.ts @@ -0,0 +1,234 @@ +/* ============================================================================= + * B05_Profile_UI_RouteEdit_Cross.ts + * 계획노선 편집 중 **한 측점의 횡단 미리보기** — 따로 뜨는 작은 창(계획서 0-9 ⑧). + * + * 보이는 것은 셋뿐이다(2026-09-12 사용자 확정) — **원지반 횡단선 · 기본 계획 횡단선 · + * 계획 횡단의 성토사면 길이**. 구조물은 그리지 않는다. + * + * ⚠ **계획고는 편집 중에 없다** — [확인] 뒤 전 체인이 낳는 값이다. 그래서 서버가 그 측점의 + * 지반고를 그대로 계획고로 놓고(지반 추종) 사면만 세운 「기본 계획 횡단」을 낸다. 확정 뒤의 + * 횡단과 다를 수 있고, 창 머리에 그렇게 적어 둔다. + * + * 셈은 **B05·B06 정본을 그대로 재사용**한다 — 측점·지반 샘플은 `generate_sections`, 설계선은 + * `compute_cross_design`(서버), 성토사면 길이는 B06 화면이 쓰는 `fillSlopeLengths`(여기). + * ========================================================================== */ + +import type { CrossSection } from "./../B06_Section/B06_Section_Api_Fetch"; +import { fillSlopeLengths } from "./../B06_Section/B06_Section_UI_Cross_Fit"; +import { fetchCrossPreview, type CrossPreviewResponse } from "./B05_Profile_Api_Replan"; + +/** 그림 가장자리 여백(px). */ +const PAD = 28; + +export interface CrossPreviewParams { + projectId: string; + /** 창을 처음 띄울 테두리(화면 좌표) — 보통 모달의 지도 칸. */ + bounds: () => DOMRect; + /** 지금 편집값 — 누른 순간에 읽어 서버로 보낸다. */ + request: () => { + vertices: Array<{ x: number; y: number; curve: boolean; radius_m: number | null }>; + min_radius_m: number; + station_interval_m: number; + }; +} + +export interface CrossPreviewWindow { + /** 그 측점의 횡단을 띄운다. 이미 떠 있으면 내용만 갈아 끼운다. */ + open: (chainageM: number) => Promise; + /** 모달을 닫을 때 — 몸통이 `document.body` 에 붙어 있어 스스로 안 사라진다. */ + destroy: () => void; +} + +export function createCrossPreview(params: CrossPreviewParams): CrossPreviewWindow { + const root = document.createElement("div"); + root.className = "b05-routeedit__cross"; + root.hidden = true; + root.innerHTML = ` +
+ 횡단 미리보기 + +
+ +
`; + document.body.append(root); + + const head = root.querySelector(".b05-routeedit__cross-head")!; + const title = root.querySelector(".b05-routeedit__cross-title")!; + const foot = root.querySelector(".b05-routeedit__cross-foot")!; + const canvas = root.querySelector(".b05-routeedit__cross-canvas")!; + const context = canvas.getContext("2d")!; + + root.querySelector(".b05-routeedit__cross-close")!.addEventListener("click", () => { + root.hidden = true; + }); + // 창 위에서 누른 것이 지도로 새어 나가면 노드가 딸려 움직인다. + for (const type of ["pointerdown", "dblclick", "contextmenu", "wheel"] as const) { + root.addEventListener(type, (event) => event.stopPropagation()); + } + + // ── 머리를 잡아 옮기기 — 노선을 가리면 손으로 치울 수 있어야 한다 ── + let dragFrom: { x: number; y: number; left: number; top: number } | null = null; + head.addEventListener("pointerdown", (event) => { + if ((event.target as HTMLElement).closest("button")) return; + dragFrom = { x: event.clientX, y: event.clientY, left: root.offsetLeft, top: root.offsetTop }; + head.setPointerCapture(event.pointerId); + event.preventDefault(); + }); + head.addEventListener("pointermove", (event) => { + if (!dragFrom) return; + root.style.left = `${Math.round(dragFrom.left + event.clientX - dragFrom.x)}px`; + root.style.top = `${Math.round(dragFrom.top + event.clientY - dragFrom.y)}px`; + }); + const stopDrag = (event: PointerEvent): void => { + if (head.hasPointerCapture(event.pointerId)) head.releasePointerCapture(event.pointerId); + dragFrom = null; + }; + head.addEventListener("pointerup", stopDrag); + head.addEventListener("pointercancel", stopDrag); + + /** 이번에 물은 측점 — 늦게 온 응답을 옛 자리에 적지 않으려고 들고 있는다. */ + let asked = -1; + + return { + async open(chainageM) { + asked = chainageM; + root.hidden = false; + if (!root.style.left) { + // 처음 열 때만 자리를 잡는다 — 그 뒤에는 사용자가 옮긴 자리를 지킨다. + // 지도 칸 **오른쪽 아래**에 붙인다 — 모달 머리·하단 정보행을 가리지 않는 자리다. + const box = params.bounds(); + root.style.left = `${Math.round(box.right - root.offsetWidth - 16)}px`; + root.style.top = `${Math.round(box.bottom - root.offsetHeight - 16)}px`; + } + title.textContent = "횡단 미리보기 — 읽는 중…"; + foot.textContent = ""; + context.clearRect(0, 0, canvas.width, canvas.height); + let preview: CrossPreviewResponse; + try { + preview = await fetchCrossPreview(params.projectId, { + ...params.request(), + chainage_m: chainageM, + }); + } catch (error) { + if (asked !== chainageM) return; + title.textContent = "횡단 미리보기"; + foot.textContent = error instanceof Error ? error.message : "횡단을 읽지 못했습니다."; + return; + } + if (asked !== chainageM || root.hidden) return; + title.textContent = `횡단 미리보기 — ${preview.label ?? `${preview.chainage_m}m`}`; + drawCross(context, canvas, preview); + foot.textContent = summarize(preview); + }, + destroy() { + root.remove(); + }, + }; +} + +/** 성토사면 길이·절성토 면적 한 줄. 계획고가 없다는 것도 여기 적는다. */ +function summarize(preview: CrossPreviewResponse): string { + const design = preview.design; + if (!design) return "계획고를 못 세워 계획 횡단을 그리지 못했습니다."; + // 성토사면 길이는 **B06 화면이 쓰는 그 함수**를 그대로 부른다 — 두 화면이 다른 길이를 + // 말하면 안 된다. 필요한 것은 `samples` 와 `design` 둘뿐이라 그만 담아 넘긴다. + const lengths = fillSlopeLengths({ + samples: preview.samples, + design, + } as unknown as CrossSection); + const sides = (["left", "right"] as const) + .filter((side) => lengths[side] !== null) + .map((side) => { + const value = lengths[side]!; + const label = side === "left" ? "좌" : "우"; + // 계산 반폭 안에서 원지반을 못 만난 사면은 거기까지만 잰 하한값이라 「≥」로 구분한다. + return `${label} ${value.open ? "≥" : ""}${value.lengthM.toFixed(2)}m`; + }); + const slope = sides.length ? `성토사면 ${sides.join(" · ")}` : "성토측 없음"; + return ( + `${slope} · 절토 ${design.cut_area_m2.toFixed(2)}㎡ · 성토 ${design.fill_area_m2.toFixed(2)}㎡` + + " · 계획고는 [확인] 뒤에 정해지므로 지반을 따라 세운 기본 계획임" + ); +} + +/** 원지반선과 기본 계획 횡단선을 한 판에 그린다. 좌(+)가 왼쪽에 오게 눕힌다. */ +function drawCross( + context: CanvasRenderingContext2D, + canvas: HTMLCanvasElement, + preview: CrossPreviewResponse, +): void { + const ground = preview.samples + .filter((sample) => sample.valid && sample.elevation_m !== null) + .map((sample) => [Number(sample.offset_m), Number(sample.elevation_m)] as [number, number]); + const design = (preview.design?.design_line ?? []).map( + (point) => [point.offset_m, point.elevation_m] as [number, number], + ); + const all = [...ground, ...design]; + context.clearRect(0, 0, canvas.width, canvas.height); + if (all.length < 2) return; + + const offsets = all.map((point) => point[0]); + const heights = all.map((point) => point[1]); + const minOffset = Math.min(...offsets); + const maxOffset = Math.max(...offsets); + const minZ = Math.min(...heights); + const maxZ = Math.max(...heights); + const spanX = maxOffset - minOffset || 1; + const spanZ = maxZ - minZ || 1; + // **가로·세로를 같은 배율로** 둔다 — 따로 늘리면 사면 기울기가 거짓으로 보인다. 횡단도는 + // 기울기를 눈으로 읽는 그림이라 왜곡하면 안 된다(2026-09-12 실화면: 노면이 안 보였다). + const scale = Math.min((canvas.width - PAD * 2) / spanX, (canvas.height - PAD * 2) / spanZ); + const centerOffset = (minOffset + maxOffset) / 2; + const centerZ = (minZ + maxZ) / 2; + // 좌(+offset)가 화면 왼쪽 — 횡단도 규약(generate_sections cad_exchange)과 같은 방향이다. + const toScreen = (point: [number, number]): [number, number] => [ + canvas.width / 2 + (centerOffset - point[0]) * scale, + canvas.height / 2 + (centerZ - point[1]) * scale, + ]; + + const stroke = (points: Array<[number, number]>, color: string, width: number): void => { + if (points.length < 2) return; + context.beginPath(); + points.forEach((point, index) => { + const [x, y] = toScreen(point); + if (index === 0) context.moveTo(x, y); + else context.lineTo(x, y); + }); + context.strokeStyle = color; + context.lineWidth = width; + context.stroke(); + }; + + // 중심선 — 어디가 노선 가운데인지 먼저 보이게. + const [centerX] = toScreen([0, centerZ]); + context.save(); + context.setLineDash([4, 4]); + context.strokeStyle = "rgba(148,163,184,0.7)"; + context.lineWidth = 1; + context.beginPath(); + context.moveTo(centerX, PAD / 2); + context.lineTo(centerX, canvas.height - PAD / 2); + context.stroke(); + context.restore(); + + stroke(ground, "#94a3b8", 1.6); // 원지반 + stroke(design, "#f97316", 2.2); // 기본 계획 횡단 + + context.font = "11px system-ui, sans-serif"; + context.textBaseline = "top"; + context.fillStyle = "#94a3b8"; + context.textAlign = "left"; + context.fillText("원지반", PAD, 6); + context.fillStyle = "#f97316"; + context.textAlign = "right"; + context.fillText("기본 계획 횡단", canvas.width - PAD, 6); + context.fillStyle = "#94a3b8"; + context.textAlign = "center"; + context.textBaseline = "bottom"; + context.fillText( + `좌 ${maxOffset.toFixed(0)}m ← 중심 → 우 ${Math.abs(minOffset).toFixed(0)}m` + + ` · 표고 ${minZ.toFixed(1)}~${maxZ.toFixed(1)}m`, + canvas.width / 2, + canvas.height - 4, + ); +} diff --git a/B05_Profile/B05_Profile_UI_RouteEdit_CurveBar.ts b/B05_Profile/B05_Profile_UI_RouteEdit_CurveBar.ts new file mode 100644 index 00000000..97f404c0 --- /dev/null +++ b/B05_Profile/B05_Profile_UI_RouteEdit_CurveBar.ts @@ -0,0 +1,152 @@ +/* ============================================================================= + * B05_Profile_UI_RouteEdit_CurveBar.ts + * 곡선 조작 패널의 **배선** — 어느 꺾임점을 만질지 정하고, 칸에서 들어온 값을 편집값에 + * 옮겨 적는다. 패널을 그리고 자리를 잡는 일은 `_Label` 몫이다. + * + * `B05_Profile_UI_RouteEdit.ts` 가 700줄을 넘겨 떼어낸 조각이다(2026-09-12). 본문 로직과 + * 수치는 그대로이고, 모달 클로저가 쥐고 있던 값만 `state()` 로 받는다. + * + * **R 과 곡선 길이는 한 쌍**(L = R·Δ) — 어느 쪽으로 들어와도 **반지름 한 값**으로 바꿔 + * 들고 간다. 두 벌로 두면 교각이 바뀔 때 서로 어긋난다(`_Edits.ts` 설명 참고). + * ========================================================================== */ + +import type { EditedCurve, EditedNode, Vertex } from "./B05_Profile_UI_RouteEdit_Curve"; +import type { CurveLock } from "./B05_Profile_UI_RouteEdit_Edits"; +import { + centerDirectionOf, + createCurveLabel, + deflectionRad, + type CurveLabel, +} from "./B05_Profile_UI_RouteEdit_Label"; + +/** 패널이 만지는 편집값 한 벌 — 모달이 쥔 배열을 그대로 건네받는다. */ +export interface CurveBarState { + picked: number; + planned: Vertex[]; + nodeInfo: EditedNode[]; + curveInfo: EditedCurve[]; + curveOn: boolean[]; + curveRadius: Array; + curveLock: CurveLock[]; + curveArc: Array; + /** 못 넘는 하한(m). 0이면 제한 없음(계획서 0-9 ④). */ + limitRadiusM: number; + limitArcM: number; +} + +export interface CurveBarParams { + canvas: HTMLCanvasElement; + state: () => CurveBarState; + toScreen: (vertex: Vertex) => [number, number]; + /** 한 번의 편집을 마무리한다 — 다시 그리고 되돌리기에 쌓는다. */ + applyEdit: (message: string) => void; +} + +export interface CurveBar { + label: CurveLabel; + /** 고른 자리에 맞춰 패널을 옮겨 그린다. */ + sync: () => void; +} + +export function createCurveBar(params: CurveBarParams): CurveBar { + const label = createCurveLabel({ + onRadius: (value) => { + const { picked, curveRadius } = params.state(); + if (picked < 0) return; + curveRadius[picked] = value; + // 반지름만 바꾼 것이라 노드 자리는 그대로지만, 그려진 선은 낡았다. + params.applyEdit(value === null ? "반지름을 자동으로 되돌렸습니다." : "반지름을 바꿨습니다."); + }, + onArcLength: (value) => { + const { picked, nodeInfo, curveArc, curveRadius } = params.state(); + 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; + params.applyEdit( + value === null ? "반지름을 자동으로 되돌렸습니다." : "곡선 길이를 바꿨습니다.", + ); + }, + onLock: (lock) => { + const { picked, nodeInfo, curveArc, curveRadius, curveLock } = params.state(); + 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; + params.applyEdit( + lock === "radius" + ? "반지름을 고정했습니다." + : lock === "arc" + ? "곡선 길이를 고정했습니다." + : "고정을 풀었습니다.", + ); + }, + onCurveOn: (on) => { + const { picked, curveOn } = params.state(); + if (picked < 0) return; + curveOn[picked] = on; + params.applyEdit(on ? "곡선을 넣었습니다." : "곡선을 지웠습니다."); + }, + }); + + /** 고른 자리에 맞춰 라벨을 옮겨 그린다. 끝점은 곡선이 없으므로 라벨을 숨긴다. */ + function sync(): void { + const { + picked, + planned, + nodeInfo, + curveInfo, + curveOn, + curveRadius, + curveLock, + limitRadiusM, + limitArcM, + } = params.state(); + if (!(picked > 0 && picked < planned.length - 1)) { + label.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 = params.canvas.getBoundingClientRect(); + const [screenX, screenY] = params.toScreen(planned[picked]); + label.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( + params.toScreen([pickedCurve.apex[0], pickedCurve.apex[1]]), + params.toScreen(pickedCurve.start), + params.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, + limitRadiusM, + limitArcM, + }); + } + + return { label, sync }; +} diff --git a/B05_Profile/B05_Profile_UI_RouteEdit_Input.ts b/B05_Profile/B05_Profile_UI_RouteEdit_Input.ts index f7f557bf..5c4256ff 100644 --- a/B05_Profile/B05_Profile_UI_RouteEdit_Input.ts +++ b/B05_Profile/B05_Profile_UI_RouteEdit_Input.ts @@ -228,6 +228,51 @@ export function routePointAtScreen( return best; } +/** + * 클릭에 가장 가까운 **규칙 측점**의 누가거리(m). 그만큼 안에 없으면 null(계획서 0-9 ⑧). + * + * 눈금을 그리는 `drawStationTicks` 와 **같은 자리**를 짚는다 — 선분 길이를 누적해 측점 간격 + * 마다 한 점씩 보간한다. 눈금이 보이는 자리를 눌렀는데 안 잡히면 안 되기 때문이다. + */ +export function stationAtScreen( + line: Array<[number, number]>, + toScreen: ScreenOf, + intervalM: number, + px: number, + py: number, + maxPx: number, +): number | null { + if (line.length < 2 || !(intervalM > 0)) return null; + const cumulative: number[] = [0]; + for (let index = 1; index < line.length; index += 1) { + cumulative.push( + cumulative[index - 1] + + Math.hypot(line[index][0] - line[index - 1][0], line[index][1] - line[index - 1][1]), + ); + } + const total = cumulative[cumulative.length - 1]; + let best: number | null = null; + let bestDistance = maxPx; + let cursor = 1; + for (let chainage = 0; chainage <= total; chainage += intervalM) { + while (cursor < cumulative.length - 1 && cumulative[cursor] < chainage) cursor += 1; + const back = line[cursor - 1]; + const front = line[cursor]; + const segment = cumulative[cursor] - cumulative[cursor - 1] || 1; + const ratio = Math.min(1, Math.max(0, (chainage - cumulative[cursor - 1]) / segment)); + const [x, y] = toScreen([ + back[0] + (front[0] - back[0]) * ratio, + back[1] + (front[1] - back[1]) * ratio, + ]); + const distance = Math.hypot(x - px, y - py); + if (distance < bestDistance) { + bestDistance = distance; + best = chainage; + } + } + return best; +} + /** 등고선을 보일 화면 사각형 — 노선 경계에 `bandM` 를 두른 것. 노선이 없으면 null. * * **매 프레임 다시 잰다** — 창 크기·배율·이동이 바뀌어도 띠가 노선을 따라간다. 띠 자체는 diff --git a/B05_Profile/B05_Profile_UI_Style_RouteEdit.css b/B05_Profile/B05_Profile_UI_Style_RouteEdit.css index 789350c7..361a73f2 100644 --- a/B05_Profile/B05_Profile_UI_Style_RouteEdit.css +++ b/B05_Profile/B05_Profile_UI_Style_RouteEdit.css @@ -241,3 +241,53 @@ color: var(--color-text-secondary); line-height: 1.35; } + +/* ── 측점 횡단 미리보기 창 (계획서 0-9 ⑧) ──────────────────────────────── + 곡선 조작 패널과 같은 까닭으로 `document.body` 에 띄운다 — 모달이 `overflow: hidden` + 이라 안에 두면 가장자리에서 잘린다. 머리를 잡아 옮길 수 있다. */ +.b05-routeedit__cross { + position: fixed; + z-index: calc(var(--z-modal, 1000) + 2); + display: flex; + flex-direction: column; + gap: var(--spacing-8, 8px); + width: 452px; + padding: var(--spacing-8); + border: 1px solid var(--color-border); + border-radius: var(--radius-8, 6px); + background: var(--color-surface-raised); + box-shadow: 0 8px 28px rgb(0 0 0 / 40%); +} + +.b05-routeedit__cross-head { + display: flex; + align-items: center; + justify-content: space-between; + gap: var(--spacing-8, 8px); + cursor: move; + touch-action: none; +} + +.b05-routeedit__cross-close { + padding: 0 6px; + border: 1px solid var(--color-border); + border-radius: var(--radius-4, 4px); + background: transparent; + color: var(--color-text-secondary); + cursor: pointer; +} + +.b05-routeedit__cross-canvas { + display: block; + width: 100%; + height: auto; + border: 1px solid var(--color-border); + border-radius: var(--radius-4, 4px); + background: var(--color-surface); +} + +.b05-routeedit__cross-foot { + color: var(--color-text-secondary); + font-size: var(--text-caption); + line-height: 1.5; +}