From d985c24f5cc2e46db83b90742e5bb2171daaa3e0 Mon Sep 17 00:00:00 2001 From: umsangdon Date: Fri, 16 Oct 2026 11:23:27 +0900 Subject: [PATCH] =?UTF-8?q?feat(=EC=A2=85=EB=8B=A8=EB=B3=B4=EA=B8=B0):=203?= =?UTF-8?q?45-3=20=EB=85=B8=EC=84=A0=20=ED=8E=B8=EC=A7=91=20=E2=86=92=20?= =?UTF-8?q?=EC=A2=85=EB=8B=A8=20=EB=B3=B4=EA=B8=B0=20=EA=B0=80=EC=9A=B4?= =?UTF-8?q?=EB=8D=B0=20=EC=9C=84=20=EB=B3=B8=EB=AC=B8(=ED=8E=98=EC=9D=B4?= =?UTF-8?q?=EC=A7=80=20=EC=88=98=EB=AA=85)=20=C2=B7=20=EC=A2=85=EB=8B=A8?= =?UTF-8?q?=EB=8F=84=20=ED=8C=90=20=ED=9D=90=EB=A6=84=20=EB=B0=B0=EC=B9=98?= =?UTF-8?q?=20=C2=B7=20[=EB=85=B8=EC=84=A0=20=EB=B0=98=EC=98=81]=20?= =?UTF-8?q?=EB=92=A4=20=EC=83=88=EB=A1=9C=EA=B3=A0=EC=B9=A8=20=EC=97=86?= =?UTF-8?q?=EC=9D=B4=20=EB=8B=A4=EC=8B=9C=20=EC=84=B8=EC=9B=80=20=C2=B7=20?= =?UTF-8?q?Ctrl+Z=20=C2=B7=20Y=20=EB=8A=94=20=EB=A7=88=EC=A7=80=EB=A7=89?= =?UTF-8?q?=EC=97=90=20=EB=A7=8C=EC=A7=84=20=ED=8C=90=20=C2=B7=20=E3=80=8C?= =?UTF-8?q?=EA=B3=84=ED=9A=8D=EB=85=B8=EC=84=A0=E3=80=8D=20=EC=B9=B8=20?= =?UTF-8?q?=C2=B7=20=ED=8E=B8=EC=A7=91=20=EB=8B=A8=EC=B6=94=20=EC=A7=80?= =?UTF-8?q?=EC=9B=80?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - mountRouteEditor(parent, host, …) — 덮개 · 전체 화면 · 닫기 걷음 · 이력 · 키 듣기 = host 보임 · 숨김 · 떠남 · 칸 크기 = ResizeObserver - [취소] → [반영 전으로](이력 처음으로 · 되돌리기로 다시 살림) · [확인] → [노선 반영] · 대기 막 = 화면 전체(노선 재탐색) - [노선 반영] 끝 = 초안 · 결과 · 옛 노선 · 종단면 캐시 비움 → remountRoute(location.reload 없앰) - R 라벨 = 칸 안 절대 위치 · 오른쪽 횡단 미리보기 = 접는 칸 · [3D] 단추 = 평면 머리 도구줄 - 새 _RouteEdit_UndoOwner(누른 판 기억) · 종단도 Ctrl+Z 줄이 물음 - 시험: test_345_3_route_body 5 · 칸 · 겉틀 시험 고쳐 씀 · 관련 411 통과(옛 3 실패 그대로) · typecheck 통과 Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01VmC34fsxxbweEG3RUjs3Mn --- A00_Common/design/design_page.css | 6 +- A00_Common/design/design_view_profile.ts | 43 ++++--- B05_Profile/B05_Profile_UI_Panel.ts | 8 +- B05_Profile/B05_Profile_UI_Panel_Route.ts | 26 ---- B05_Profile/B05_Profile_UI_Panel_Sections.ts | 22 +--- .../B05_Profile_UI_Profile_Panel_Tools.ts | 2 + B05_Profile/B05_Profile_UI_RouteEdit.ts | 75 +++++++---- B05_Profile/B05_Profile_UI_RouteEdit_Apply.ts | 16 ++- .../B05_Profile_UI_RouteEdit_Chrome.ts | 54 +++++--- .../B05_Profile_UI_RouteEdit_CurveBar.ts | 119 +++++++++--------- .../B05_Profile_UI_RouteEdit_History.ts | 17 ++- B05_Profile/B05_Profile_UI_RouteEdit_Label.ts | 35 +++--- .../B05_Profile_UI_RouteEdit_UndoOwner.ts | 33 +++++ B05_Profile/B05_Profile_UI_Style.css | 13 +- .../B05_Profile_UI_Style_RouteEdit.css | 71 +++++++---- resources/tester/helper_311_panel_folded.cjs | 7 +- resources/tester/helper_345_3_undo_owner.cjs | 91 ++++++++++++++ .../tester/helper_design_view_parity.cjs | 3 - resources/tester/test_311_panel_folded.py | 7 +- resources/tester/test_345_2_3d_modal.py | 2 +- resources/tester/test_345_3_route_body.py | 93 ++++++++++++++ .../tester/test_b05_routeedit_chrome_modal.py | 78 ++++++++---- resources/tester/test_design_page_single.py | 20 ++- resources/tester/test_design_view_parity.py | 6 +- 24 files changed, 564 insertions(+), 283 deletions(-) delete mode 100644 B05_Profile/B05_Profile_UI_Panel_Route.ts create mode 100644 B05_Profile/B05_Profile_UI_RouteEdit_UndoOwner.ts create mode 100644 resources/tester/helper_345_3_undo_owner.cjs create mode 100644 resources/tester/test_345_3_route_body.py diff --git a/A00_Common/design/design_page.css b/A00_Common/design/design_page.css index 7ffb67950..53f92167a 100644 --- a/A00_Common/design/design_page.css +++ b/A00_Common/design/design_page.css @@ -10,8 +10,8 @@ display: none; } -/* 가운데 = 위 칸(보기마다 다름) + 아래 종단도 한 벌. 종단 보기에서 종단도는 3D 위에 떠 있는 - 판(absolute · 3D 크기가 안 바뀜)이고, 횡단 보기에서는 카드 칸 아래 흐름에 선다. */ +/* 가운데 = 위 칸(보기마다 다름) + 아래 종단도 한 벌. 종단도는 두 보기 모두 위 칸 아래 흐름에 + 선다(345-3 — 종단 보기 위 칸 = 평면 노선 편집 · 3D 는 모달) · 높이 끌기는 그대로. */ .design-page__main { position: relative; display: flex; @@ -35,7 +35,7 @@ min-height: 0; } -.design-page__main[data-view="cross"] > .design-page__profile > .b05-route-profile { +.design-page__main > .design-page__profile > .b05-route-profile { position: relative; flex: none; } diff --git a/A00_Common/design/design_view_profile.ts b/A00_Common/design/design_view_profile.ts index 9044fe2c1..319930f89 100644 --- a/A00_Common/design/design_view_profile.ts +++ b/A00_Common/design/design_view_profile.ts @@ -1,6 +1,7 @@ /* ============================================================================= * design_view_profile.ts - * 한 페이지의 **종단 보기**(계획서 24-3b) — 3D 화면은 [3D] 큰 모달(345-2). 좌측 패널 · 아래 단추 · + * 한 페이지의 **종단 보기**(계획서 24-3b) — 가운데 위 칸 = 평면(노선 편집 · 345-3) · 3D 화면은 + * [3D] 큰 모달(345-2). 좌측 패널 · 아래 단추 · * 종단도(유토곡선 · 배수유역도)는 한 벌(`design_shared`)을 횡단 보기와 같은 노드로 쓴다. * * 여기서는 종단 보기 몫만 건다 — 3D · 좌측 종단 칸 재료 · 구조물 다리 · 종단도 알림 · 저장 몫. @@ -18,7 +19,7 @@ import { createRoutePanel } from "../../B05_Profile/B05_Profile_UI_Panel"; import { createRouteProfilePanel } from "../../B05_Profile/B05_Profile_UI_Profile_Panel"; import { leaveForDashboard } from "../b_missing_data_guard"; import { currentRoute, remountRoute } from "../router"; -import { openRouteEditModal } from "../../B05_Profile/B05_Profile_UI_RouteEdit"; +import { mountRouteEditor } from "../../B05_Profile/B05_Profile_UI_RouteEdit"; import { createSelectionSync, graphTargetAt } from "../../B05_Profile/B05_Profile_UI_Selection"; import { readStructurePick, @@ -46,6 +47,7 @@ import { rememberStandardDefaults, } from "../../B06_Section/B06_Section_UI_Standard_Panel"; import { fetchSectionContext } from "../../B06_Section/B06_Section_Api_Fetch"; +import { invalidateSectionDetail } from "../../B06_Section/B06_Section_Section_Store"; import { onStaleStructureTypes, structureAnchorM, @@ -270,18 +272,6 @@ export async function mountProfileView( const panel = createRoutePanel( { - // 계획노선 편집 — 모달 [확인]에서 서버가 배수유역부터 다시 계산하고 그 결과가 새 초기값이 - // 된다. 옛 노선 캐시는 모달이 비우고, 페이지는 통째로 다시 읽는다 — 제자리 다시 세우기는 - // 옛 노선 관 목록 등 메모리 · 진행 중 요청이 살아남아 [저장]에 실렸다(PLAN 77). - onEditPlannedRoute: () => - void openRouteEditModal(activeProjectId, () => location.reload(), { - // 측점 눈금 간격은 좌측 패널이 쥔 값을 그대로 넘긴다 — 모달이 따로 굳히지 않는다. - stationIntervalM: panel.values().stationInterval ?? undefined, - // 바탕 등고선 — 확정 지표면이 있으면 모달이 LAS 등고선을 쓴다(계획서 0-9 ⑥). - surfaceModelId: state.confirmedSurface?.model_id ?? null, - contourIntervalM: state.latest?.surface_params.contour_interval_m, - smooth: state.latest?.surface_params.smooth, - }), onContourApply: (interval) => view3d.applyContours(interval), ...view3d.viewCallbacks, onInputChange: markStale, @@ -420,6 +410,16 @@ export async function mountProfileView( }, }); + /** [노선 반영] 끝 — 서버가 배수유역부터 다시 셌고 그 결과가 새 초기값이다. 새로고침 대신 페이지를 + * 통째로 다시 세운다(345-3) — 제자리 고침은 옛 노선 관 목록 등 메모리 · 진행 중 요청이 살아남아 + * [저장]에 실렸다(PLAN 77). 초안 · 결과 칸은 [노선 반영]이 비웠고 옛 노선 캐시는 여기서 비운다 · + * 떠나는 페이지의 늦은 응답은 `host.disposed()` 로 버려진다. */ + const rebuildAfterReplan = (): void => { + clearRouteLatestCache(activeProjectId); + invalidateSectionDetail(activeProjectId); + remountRoute(currentRoute()); + }; + /* ── 진입 로딩 ───────────────────────────────────────────────────────── * 화면 틀을 먼저 띄우고 자료가 끝나는 순서대로 채운다(2026-08-01 사용자 지시). * 3D 지형은 **보조 자료라 로딩에 넣지 않는다**(2026-09-04 사용자 지시) — [3D] 모달을 처음 열 때 @@ -427,10 +427,7 @@ export async function mountProfileView( const LOAD_STEP_COUNT = 3; const mainContent = document.createElement("div"); mainContent.className = "b05-route__main"; - const planTools = document.createElement("div"); - planTools.className = "b05-route__plan-tools"; - planTools.append(view3d.modal.button); - mainContent.append(planTools, view3d.modal.park); + mainContent.append(view3d.modal.park); center.append(mainContent); const progress = createProgressCircle({ label: "노선 정보를 불러오는 중…", overlay: true }); mainContent.append(progress.root); @@ -481,6 +478,16 @@ export async function mountProfileView( return; } advanceLoading("종단면 자료를 불러오는 중…"); + // 평면(노선 편집) — 가운데 위 본문 · 페이지 수명(345-3). 자료는 페이지 열기와 함께 읽는다. + void mountRouteEditor(mainContent, host, activeProjectId, rebuildAfterReplan, { + // 측점 눈금 간격은 좌측 패널이 쥔 값을 그대로 넘긴다 — 편집기가 따로 굳히지 않는다. + stationIntervalM: panel.values().stationInterval ?? undefined, + // 바탕 등고선 — 확정 지표면이 있으면 LAS 등고선을 쓴다(계획서 0-9 ⑥). + surfaceModelId: state.confirmedSurface.model_id, + contourIntervalM: latestResponse.surface_params.contour_interval_m, + smooth: latestResponse.surface_params.smooth, + tools: [view3d.modal.button], + }); // ③ 종단면·횡단 자료 — 하단 패널을 3D보다 먼저 채운다. if (latestResponse.route) await restoreSections(latestResponse.route.id); diff --git a/B05_Profile/B05_Profile_UI_Panel.ts b/B05_Profile/B05_Profile_UI_Panel.ts index b8a70d0a0..d4e7cb6fd 100644 --- a/B05_Profile/B05_Profile_UI_Panel.ts +++ b/B05_Profile/B05_Profile_UI_Panel.ts @@ -3,7 +3,7 @@ * 종단 보기(B05) 좌측 칸 재료 — 한 벌 좌측 패널(`design_shared`)에 종단 보기 몫을 넣는다. * * 칸 정의는 `_Panel_Sections`(종단 보기 전용) · `design_sections`(두 보기 공용), 재료는 - * `_Panel_Settings` · `_Panel_Route` · `_Panel_View`, 값 · 상수 · 입력 칸 만들기는 `_Panel_Fields` + * `_Panel_Settings` · `_Panel_View`, 값 · 상수 · 입력 칸 만들기는 `_Panel_Fields` * 에 있다(700줄 제한으로 나눔, 계획서 24-2b). 값을 읽고 되살리는 창구(`values` · `restore`)만 * 여기 남는다. 아래 고정 영역(단추 넷) · 「구조물 배치」 폼은 한 벌이 두 보기에 같은 노드로 준다. * ========================================================================== */ @@ -23,15 +23,12 @@ import { parseOptional, type RoutePanelValues, } from "./B05_Profile_UI_Panel_Fields"; -import { createRoutePart } from "./B05_Profile_UI_Panel_Route"; import { createSettingsPart } from "./B05_Profile_UI_Panel_Settings"; import { createViewControls } from "./B05_Profile_UI_Panel_View"; export type { RoutePanelValues } from "./B05_Profile_UI_Panel_Fields"; interface PanelCallbacks { - /** [계획노선 편집] — 큰 모달을 열어 노선을 고친다(계산은 모달 [확인]에서만 돈다). */ - onEditPlannedRoute: () => void; onContourApply: (interval: number) => void | Promise; onSurfaceVisible: (visible: boolean) => void; /** [예상형상] — 계획 코리도 서피스 + 클리핑 지형(공사 후) ↔ 원지반 완전체 전환. */ @@ -74,7 +71,6 @@ interface PanelCallbacks { export function createRoutePanel(callbacks: PanelCallbacks, shared: DesignPageShared) { const { viewControls, setCorridorPending } = createViewControls(callbacks); const settings = createSettingsPart(callbacks); - const route = createRoutePart(callbacks); const { contourInterval, gradeClass, @@ -123,7 +119,7 @@ export function createRoutePanel(callbacks: PanelCallbacks, shared: DesignPageSh // 좌측 패널 = 한 벌 등록부(`design_shared`) — 종단 보기 칸 재료만 넣는다. 아래 단추 넷 · 저장 · // [확정]은 한 벌이 두 보기 몫을 모아 낸다. - Object.assign(shared.parts, { settings, route }); + Object.assign(shared.parts, { settings }); sharedPanel(shared); return { diff --git a/B05_Profile/B05_Profile_UI_Panel_Route.ts b/B05_Profile/B05_Profile_UI_Panel_Route.ts deleted file mode 100644 index 95482457e..000000000 --- a/B05_Profile/B05_Profile_UI_Panel_Route.ts +++ /dev/null @@ -1,26 +0,0 @@ -/* ============================================================================= - * B05_Profile_UI_Panel_Route.ts - * 좌측 패널의 **노선 쪽 칸 재료** — 계획노선 편집 단추. 칸 틀은 - * `B05_Profile_UI_Panel_Sections.ts` 가 씌운다(등록부). 「경로 계산 설정」은 `Old/` 로 옮김(54-24). - * 「선택 포인트 상세」(3D 노선 점 옮김 · 지움)는 지움(345-2). - * `B05_Profile_UI_Panel.ts` 에서 떼어냈다(계획서 24-2b). 본문 로직과 수치는 그대로다. - * ========================================================================== */ - -import { button } from "./B05_Profile_UI_Panel_Fields"; - -export interface RoutePartCallbacks { - /** [계획노선 편집] — 큰 모달을 열어 노선을 고친다(계산은 모달 [확인]에서만 돈다). */ - onEditPlannedRoute: () => void; -} - -export function createRoutePart(callbacks: RoutePartCallbacks) { - // 계획노선 편집 — 노선을 고치는 유일한 입구(2026-09-06 사용자 확정). 자동탐색 - // (「경로 계산 설정」)은 사용 중단이라 이 자리가 노선을 바꾸는 길이다. - const plannedRouteBtn = button("계획노선 편집", () => callbacks.onEditPlannedRoute()); - plannedRouteBtn.classList.add("b05-route__btn"); - plannedRouteBtn.title = "예상노선 위에서 계획노선을 고칩니다. [확인] 때만 다시 계산합니다."; - - return { plannedRouteButton: plannedRouteBtn }; -} - -export type RoutePart = ReturnType; diff --git a/B05_Profile/B05_Profile_UI_Panel_Sections.ts b/B05_Profile/B05_Profile_UI_Panel_Sections.ts index 417e064da..67c28ff32 100644 --- a/B05_Profile/B05_Profile_UI_Panel_Sections.ts +++ b/B05_Profile/B05_Profile_UI_Panel_Sections.ts @@ -1,16 +1,16 @@ /* ============================================================================= * B05_Profile_UI_Panel_Sections.ts - * 종단 보기 좌측 패널의 **칸 정의**(등록부 `registerPanelSection`) — 페이지 설정 · 계획노선. + * 종단 보기 좌측 패널의 **칸 정의**(등록부 `registerPanelSection`) — 페이지 설정. * 「구조물 배치」는 두 보기 공용이라 `design_sections.ts` 에 있다. * - * 칸 차례(2026-08-18 사용자 확정): 구조물 배치 > 페이지 설정 > 계획노선 > 아래 고정 영역. - * 「선택 포인트 상세 설정」(3D 노선 점 옮김 · 지움 · 저장 안 되는 길)은 지움(345-2). 「경로 계산 설정」은 `Old/` 로 옮김(54-24). 재료는 `_Panel_Settings` · `_Panel_Route` 가 만들어 - * `ctx.parts` 로 건넨다. + * 칸 차례(2026-08-18 사용자 확정): 구조물 배치 > 페이지 설정 > 아래 고정 영역. + * 「선택 포인트 상세 설정」(3D 노선 점 옮김 · 지움 · 저장 안 되는 길)은 지움(345-2) · + * 「계획노선」 칸 · [계획노선 편집]은 지움(345-3 — 노선 편집은 가운데 위 본문). + * 「경로 계산 설정」은 `Old/` 로 옮김(54-24). 재료는 `_Panel_Settings` 가 만들어 `ctx.parts` 로 건넨다. * ========================================================================== */ import "../A00_Common/design/design_sections"; import { createSectionFrame, part, registerPanelSection } from "../A00_Common/design/design_panel"; -import type { RoutePart } from "./B05_Profile_UI_Panel_Route"; import type { SettingsPart } from "./B05_Profile_UI_Panel_Settings"; registerPanelSection({ @@ -24,15 +24,3 @@ registerPanelSection({ return frame.root; }, }); - -registerPanelSection({ - id: "planned-route", - title: "계획노선", - views: ["profile"], - order: 30, - build: (ctx, view) => { - const frame = createSectionFrame(view, "계획노선", true); // 311 기본 접힘 - frame.body.append(part(ctx, "route").plannedRouteButton); - return frame.root; - }, -}); diff --git a/B05_Profile/B05_Profile_UI_Profile_Panel_Tools.ts b/B05_Profile/B05_Profile_UI_Profile_Panel_Tools.ts index f8a1a2950..ecd2cee21 100644 --- a/B05_Profile/B05_Profile_UI_Profile_Panel_Tools.ts +++ b/B05_Profile/B05_Profile_UI_Profile_Panel_Tools.ts @@ -32,6 +32,7 @@ import { createFreeDraw } from "../A00_Common/design/design_profile_free"; import { createFreeView, type FreeChartGeom } from "../A00_Common/design/design_profile_free_view"; import { arrowsBelongToProfile } from "../B06_Section/B06_Section_UI_Cross_Panel_Base"; import { stepStructureStation } from "../common_util/common_util_structure_station"; +import { undoOwner } from "./B05_Profile_UI_RouteEdit_UndoOwner"; /** 방향키 ▲▼ 한 번에 움직이는 계획고(m) — 좌우(구조물 측점)는 1 m 걸음(234-1 · `stepStructureStation`). */ const KEY_STEP_M = 0.1; @@ -320,6 +321,7 @@ export function createPanelTools(ctx: PanelToolsContext): PanelTools { return; } if (isCtrlKey(event, "z")) { + if (undoOwner() === "plan") return; // 평면(노선 편집)을 마지막에 만졌다(345-3). event.preventDefault(); flushHold(); if (event.shiftKey) history.redo(); diff --git a/B05_Profile/B05_Profile_UI_RouteEdit.ts b/B05_Profile/B05_Profile_UI_RouteEdit.ts index 65d2759b3..e6ea19bb8 100644 --- a/B05_Profile/B05_Profile_UI_RouteEdit.ts +++ b/B05_Profile/B05_Profile_UI_RouteEdit.ts @@ -1,10 +1,11 @@ /* ============================================================================= * B05_Profile_UI_RouteEdit.ts - * 계획노선 편집 모달 — 예상노선(점선) 위에 계획노선(실선)을 고쳐 그린다. + * 계획노선 편집(평면) — 예상노선(점선) 위에 계획노선(실선)을 고쳐 그린다. * - * 왜 모달인가(2026-09-06 사용자 확정) — [확인]을 누르면 배수유역부터 종·횡단·유토곡선까지 - * 전 단계가 다시 도는 무거운 작업이다(용화 67측점 3분대). 신중히 하라는 뜻으로 큰 모달을 - * 쓰고, **편집 중에는 아무 계산도 나가지 않는다**. + * 자리(345-3) — 모달이 아니라 종단 보기 가운데 위 본문(옛 3D 자리)이고 **페이지 수명**이다. + * 이력 · 키 듣기는 보기의 보임 · 숨김 · 떠남(`host`)을 따른다. [노선 반영]을 누르면 배수유역부터 + * 종·횡단·유토곡선까지 다시 돌고(무거움), 끝나면 부른 쪽이 새로고침 없이 페이지를 다시 세운다. + * **편집 중에는 아무 계산도 나가지 않는다**(띠 · 기울기 · 횡단 미리보기 묻기만). * * 노선은 두 벌이다 — 예상노선(원본, 안 바뀜)과 계획노선(수정본, 사용자가 고침). * [초기화]는 마지막에 적용한 초기노선 갈래로 편집값만 되돌린다(계획서 20 · `_Initial`). @@ -82,6 +83,7 @@ import { type RouteEditHistory, type RouteEditSnapshot, } from "./B05_Profile_UI_RouteEdit_History"; +import type { DesignPageHost } from "../A00_Common/design/design_page"; import "./B05_Profile_UI_Style_RouteEdit.css"; /** 등고선 눈금을 고를 때 쓸 라벨 예산 — B04 지도 기본값(350)보다 **넉넉히** 둔다 @@ -91,7 +93,7 @@ const CONTOUR_LEVEL_BUDGET = 1200; type Vertex = [number, number]; -/** 모달을 연다. [확인]이 끝나면 `onApplied`를 부른다(화면 다시 읽기). */ +/** 편집기 곁값. [노선 반영]이 끝나면 `onApplied`를 부른다(화면 다시 세우기). */ export interface RouteEditOptions { /** 규칙 측점 간격(m) — 좌측 패널이 쥔 값을 그대로 받는다(코드에 굳히지 않는다). */ stationIntervalM?: number; @@ -100,15 +102,23 @@ export interface RouteEditOptions { /** 등고선 간격(m)·평활 여부 — 3D 뷰어가 쓰는 값 그대로. */ contourIntervalM?: number; smooth?: boolean; + /** 머리 도구줄 끝에 앉힐 부른 쪽 단추([3D] 등). */ + tools?: HTMLElement[]; } -export async function openRouteEditModal( +/** 편집기가 사는 보기 — 보임 · 숨김 · 떠남. */ +export type RouteEditHost = Pick; + +/** 편집기를 `parent` 에 세운다 — 자료를 읽고 나면 끝난다(떠날 때 정리는 `host.onDispose`). */ +export async function mountRouteEditor( + parent: HTMLElement, + host: RouteEditHost, projectId: string, onApplied: () => void | Promise, options: RouteEditOptions = {}, ): Promise { const stationIntervalM = options.stationIntervalM ?? 20; - const chrome = createRouteEditChrome(); + const chrome = createRouteEditChrome(parent, options.tools); const { overlay, canvas, status, busy, measureBox, measureText, measureButton } = chrome; const { minorContourButton, terrainSelect, stationSnapButton } = chrome; const context = canvas.getContext("2d")!; @@ -248,12 +258,13 @@ export async function openRouteEditModal( meta && viewReady ? captureView(meta, view, rotation.radians()) : null, ); - const close = (): void => { + /** 떠날 때 — 페이지가 버린다(345-3 · 닫기 단추 없음). */ + const dispose = (): void => { viewMemory.flush(); closed = true; - window.removeEventListener("resize", resize); - historyControls.dispose(); // 단축키는 창(window)에 달려 있어 안 떼면 닫힌 뒤에도 산다. - curveLabelBox.destroy(); // 패널은 `document.body` 에 붙어 있어 스스로 안 사라진다. + sizeWatch.disconnect(); + historyControls.dispose(); // 단축키는 창(window)에 달려 있어 안 떼면 떠난 뒤에도 산다. + curveLabelBox.destroy(); overlay.remove(); }; /** 초기노선 갈래 — 로직 단추 · [초기화] · 비교값(계획서 20). 서버 묻기는 `_Initial` 몫. */ @@ -279,22 +290,36 @@ export async function openRouteEditModal( criteria: initial.current().criteria, }), }); - const cancel = (): void => (initial.cancel(), close()); // 이 창에서 바꾼 초기값도 버린다. - overlay.querySelector(".b05-routeedit__close")!.addEventListener("click", cancel); - overlay.querySelector('[data-act="cancel"]')!.addEventListener("click", cancel); - // 배경 클릭으로 닫지 않는다 — 고치던 노선을 실수로 날리지 않게. + // [반영 전으로] — 마지막 반영(페이지를 세운 때) 노선 · 그 걸음의 「현재 추천」으로. 되돌리기로 다시 살림. + overlay.querySelector('[data-act="cancel"]')!.addEventListener("click", () => { + const snapshot = history?.reset(); + if (!snapshot) return showToast("반영 뒤 고친 노선이 없습니다.", "info"); + initial.restoreTag(snapshot.rec); + restoreSnapshot(snapshot, "반영 전 노선으로 되돌렸습니다.", false); + }); function resize(): void { - if (closed) return; + if (closed || !overlay.isConnected) return; // 크기가 바뀌어도 가운데 자리 · 배율을 지킨다 — px 이동량을 그대로 두면 지도가 밀려 // 노선이 화면 밖으로 나가고, 그 자리가 보던 화면으로 저장돼 다시 열어도 빈 화면이 된다. const kept = meta && viewReady ? captureView(meta, view, rotation.radians()) : null; const { width, height } = fitCanvas(canvas, context); view = { ...view, width, height, mapRect: computeMapRect(meta, width, height) }; if (meta && kept) view = { ...view, ...restoreSavedView(meta, view, kept) }; + if (!viewReady) openView(); // 숨은 채 섰으면(크기 0) 처음 보일 때 맞춘다. draw(); } - window.addEventListener("resize", resize); + // 칸 크기를 따라감 — 창 크기 · 아래 종단 판 끌기 · 좌측 접기 모두(345-3). + const sizeWatch = new ResizeObserver(() => resize()); + sizeWatch.observe(overlay.querySelector(".b05-routeedit__canvas-wrap")!); + let viewWanted = false; // 자료를 읽었다 — 칸 크기가 서면 보던 화면(18 ③) · 없으면 노선 전체로. + function openView(): void { + if (!viewWanted || !meta || view.width <= 0) return; + const opening = openingView(projectId, meta, view, planned); + view = opening.view; + rotation.set(opening.rotationRad); + viewReady = true; + } const toScreen = (vertex: Vertex): [number, number] => meta ? metricToScreen(meta, view, vertex[0], vertex[1]) : [0, 0]; @@ -401,6 +426,7 @@ export async function openRouteEditModal( // ── 곡선 라벨 — 고른 꺾임점 옆에 뜨는 조작 패널. 배선은 `_CurveBar` 몫 ── const curveBar = createCurveBar({ canvas, + container: overlay, state: () => ({ picked, planned, @@ -566,10 +592,15 @@ export async function openRouteEditModal( stationSnap: () => stationSnap, initial, profileShown: strip.settle, - close, onApplied, }); + // 페이지 수명 — 보일 때만 키를 듣고 숨으면 뗌 · 떠나면 정리(design_page host · 345-3). + historyControls.listen(host.visible()); + host.onShow(() => (historyControls.listen(true), resize())); + host.onHide(() => (historyControls.listen(false), viewMemory.flush())); + host.onDispose(dispose); + // ── 자료 읽기 — 노선 두 벌 + 등고선 도엽(배수유역도와 같은 것) ── try { const [plan, drainage, sources] = await Promise.all([ @@ -654,12 +685,8 @@ export async function openRouteEditModal( draw(); }); }); - resize(); - // 보던 화면이 있으면 그대로 연다(계획서 18 ③). 없으면 노선 전체에 맞춘다. - const opening = openingView(projectId, meta, view, planned); - view = opening.view; - rotation.set(opening.rotationRad); - viewReady = true; + viewWanted = true; + resize(); // 칸 크기가 서 있으면 여기서 보던 화면을 연다(아니면 처음 보일 때). showStatus(); draw(); strip.refresh(); diff --git a/B05_Profile/B05_Profile_UI_RouteEdit_Apply.ts b/B05_Profile/B05_Profile_UI_RouteEdit_Apply.ts index 05f05f7d2..6096b7019 100644 --- a/B05_Profile/B05_Profile_UI_RouteEdit_Apply.ts +++ b/B05_Profile/B05_Profile_UI_RouteEdit_Apply.ts @@ -1,9 +1,10 @@ /* ============================================================================= * B05_Profile_UI_RouteEdit_Apply.ts - * 계획노선 편집 모달의 **[확인]** — 무거운 재계산과 대기 표시. + * 계획노선 편집(평면)의 **[노선 반영]**(옛 [확인] · 346-3 까지 남김) — 무거운 재계산과 대기 표시. * - * 다시 계산은 **노선이 바뀐 뒤 [확인]에서만** 돈다(계획서 20 ④). 노선이 창을 연 그대로면 - * 계산 없이 닫는다. [초기화](옛 「예상노선으로」)는 편집값만 되돌린다(`_Initial`). + * 다시 계산은 **노선이 바뀐 뒤 [노선 반영]에서만** 돈다(계획서 20 ④). 노선이 마지막 반영 그대로면 + * 계산하지 않는다. [초기화](옛 「예상노선으로」)는 편집값만 되돌린다(`_Initial`). 끝나면 부른 쪽이 + * 페이지를 새로고침 없이 다시 세운다(`onApplied`). * * 누르면 서버가 배수유역부터 종·횡단·유토곡선까지 전 단계를 다시 돈다(약 90초). 중간 취소는 * 만들지 않기로 했으므로(계획서 0-2, 2026-09-09) **얼마나 지났는지**를 초로 보여 사람이 @@ -23,7 +24,7 @@ export interface RouteApplyParams { projectId: string; /** 지금 편집값 — 누른 순간에 읽는다. */ nodes: () => { planned: Vertex[]; curveOn: boolean[]; curveRadius: Array }; - /** 창을 연 뒤 노선이 바뀌었나 — 안 바뀌었으면 다시 계산하지 않는다. */ + /** 마지막 반영 뒤 노선이 바뀌었나 — 안 바뀌었으면 다시 계산하지 않는다. */ changed: () => boolean; /** 설계 지형으로 보낼 분석 지형(PLAN 133-2) — 고르기가 안 보이면 null(지금 설계 지형 그대로). */ terrain: () => string | null; @@ -36,12 +37,11 @@ export interface RouteApplyParams { }; /** 종단 띠 — 응답을 기다려 띠에 보인 계획선(균형 종단 · 지반고)을 그대로 적용한다(PLAN 75-1). */ profileShown: () => Promise; - /** 성공하면 모달을 닫고 화면을 다시 읽는다. */ - close: () => void; + /** 성공하면 화면을 다시 세운다(대기 막은 다시 세울 때까지 남음). */ onApplied: () => void | Promise; } -/** [확인]을 붙인다. 리스너는 모달과 수명이 같다. */ +/** [노선 반영]을 붙인다. 리스너는 편집기와 수명이 같다. */ export function bindRouteApply(params: RouteApplyParams): void { const { overlay, busy, projectId } = params; @@ -64,7 +64,6 @@ export function bindRouteApply(params: RouteApplyParams): void { clearResults(projectId); params.initial.keep(); // 초기값은 새 노선의 출발점 — 옛 노선 결과와 함께 버리지 않는다. showToast("노선을 다시 계산했습니다.", "success"); - params.close(); await params.onApplied(); } catch (error) { busy.hidden = true; @@ -82,7 +81,6 @@ export function bindRouteApply(params: RouteApplyParams): void { } if (!params.changed()) { showToast("노선이 그대로라 다시 계산하지 않았습니다.", "info"); - params.close(); return; } void runHeavy("계획노선 반영", async () => diff --git a/B05_Profile/B05_Profile_UI_RouteEdit_Chrome.ts b/B05_Profile/B05_Profile_UI_RouteEdit_Chrome.ts index c6e66feee..0148d3de1 100644 --- a/B05_Profile/B05_Profile_UI_RouteEdit_Chrome.ts +++ b/B05_Profile/B05_Profile_UI_RouteEdit_Chrome.ts @@ -1,9 +1,8 @@ import { createButton } from "@ui/ui_template_elements"; -import "@ui/ui_template_modal.css"; /* ============================================================================= * B05_Profile_UI_RouteEdit_Chrome.ts - * 계획노선 편집 모달의 **뼈대** — 창·단추·오버레이 판을 만들고 자주 쓰는 요소를 집어 준다. + * 계획노선 편집(평면)의 **뼈대** — 칸·단추·오버레이 판을 만들고 자주 쓰는 요소를 집어 준다. * * `B05_Profile_UI_RouteEdit.ts` 가 700줄을 넘겨 떼어낸 조각이다(2026-09-12). 생김새만 있고 * 동작은 없다 — 배선은 본체와 각 조각(`_Apply`·`_Rotate`·`_History`…)이 한다. @@ -12,8 +11,9 @@ import "@ui/ui_template_modal.css"; * 단추는 제목행 오른쪽, 조작 설명은 지도 왼쪽 위 2열, 상태·범례는 왼쪽 아래, 잰 값은 오른쪽 * 아래. 메인 창은 왼쪽으로 밀고 오른쪽 세로 칸에 횡단 두 판이 앉는다. * - * 겉틀 — 배경 · 제목줄은 공용 모달 조각(`ui-modal__backdrop` · `ui-modal__top`). `openModal` 은 - * 안 씀 — 바깥 누름 · Esc 로 닫혀 고치던 노선을 날리고, 제목줄에 단추 묶음 · 오른쪽 횡단 칸이 못 앉음. + * 자리(345-3) — 모달이 아니라 종단 보기 가운데 위 본문(옛 3D 자리)에 붙는다. 덮개 · 전체 화면 · + * 닫기 단추는 걷었다 · [취소] → [반영 전으로] · [확인] → [노선 반영] · 오른쪽 횡단 칸은 접는 칸. + * 머리 끝 `tools` 자리에 부른 쪽 단추([3D])가 앉는다. * ========================================================================== */ /** 회전 단추 아이콘 — **반만 도는 화살표**(2026-09-12 사용자 지시 ㉙). 한 바퀴를 다 그린 @@ -42,16 +42,20 @@ export interface RouteEditChrome { terrainSelect: HTMLSelectElement; } -/** 모달을 만들어 `document.body` 에 붙이고, 자주 쓰는 요소를 집어 돌려준다. */ -export function createRouteEditChrome(): RouteEditChrome { +/** 뼈대를 만들어 `parent` 에 붙이고, 자주 쓰는 요소를 집어 돌려준다. */ +export function createRouteEditChrome( + parent: HTMLElement, + tools: HTMLElement[] = [], +): RouteEditChrome { const overlay = document.createElement("div"); - overlay.className = "ui-modal__backdrop b05-routeedit"; + overlay.className = "b05-routeedit"; overlay.innerHTML = ` -