feat(종단보기): 345-3 노선 편집 → 종단 보기 가운데 위 본문(페이지 수명) · 종단도 판 흐름 배치 · [노선 반영] 뒤 새로고침 없이 다시 세움 · Ctrl+Z · Y 는 마지막에 만진 판 · 「계획노선」 칸 · 편집 단추 지움

- 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 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01VmC34fsxxbweEG3RUjs3Mn
This commit is contained in:
2026-10-16 11:23:27 +09:00
co-authored by Claude Opus 5.5
parent f8fcb812d3
commit d985c24f5c
24 changed files with 564 additions and 283 deletions
+3 -3
View File
@@ -10,8 +10,8 @@
display: none; display: none;
} }
/* 가운데 = 위 칸(보기마다 다름) + 아래 종단도 한 벌. 종단 보기에서 종단도는 3D 위에 떠 있는 /* 가운데 = 위 칸(보기마다 다름) + 아래 종단도 한 벌. 종단도는 두 보기 모두 위 칸 아래 흐름에
판(absolute · 3D 크기가 안 바뀜)이고, 횡단 보기에서는 카드 칸 아래 흐름에 선다. */ 선다(345-3 — 종단 보기 위 칸 = 평면 노선 편집 · 3D 는 모달) · 높이 끌기는 그대로. */
.design-page__main { .design-page__main {
position: relative; position: relative;
display: flex; display: flex;
@@ -35,7 +35,7 @@
min-height: 0; 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; position: relative;
flex: none; flex: none;
} }
+25 -18
View File
@@ -1,6 +1,7 @@
/* ============================================================================= /* =============================================================================
* design_view_profile.ts * design_view_profile.ts
* 한 페이지의 **종단 보기**(계획서 24-3b) — 3D 화면은 [3D] 큰 모달(345-2). 좌측 패널 · 아래 단추 · * 한 페이지의 **종단 보기**(계획서 24-3b) — 가운데 위 칸 = 평면(노선 편집 · 345-3) · 3D 화면은
* [3D] 큰 모달(345-2). 좌측 패널 · 아래 단추 ·
* 종단도(유토곡선 · 배수유역도)는 한 벌(`design_shared`)을 횡단 보기와 같은 노드로 쓴다. * 종단도(유토곡선 · 배수유역도)는 한 벌(`design_shared`)을 횡단 보기와 같은 노드로 쓴다.
* *
* 여기서는 종단 보기 몫만 건다 — 3D · 좌측 종단 칸 재료 · 구조물 다리 · 종단도 알림 · 저장 몫. * 여기서는 종단 보기 몫만 건다 — 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 { createRouteProfilePanel } from "../../B05_Profile/B05_Profile_UI_Profile_Panel";
import { leaveForDashboard } from "../b_missing_data_guard"; import { leaveForDashboard } from "../b_missing_data_guard";
import { currentRoute, remountRoute } from "../router"; 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 { createSelectionSync, graphTargetAt } from "../../B05_Profile/B05_Profile_UI_Selection";
import { import {
readStructurePick, readStructurePick,
@@ -46,6 +47,7 @@ import {
rememberStandardDefaults, rememberStandardDefaults,
} from "../../B06_Section/B06_Section_UI_Standard_Panel"; } from "../../B06_Section/B06_Section_UI_Standard_Panel";
import { fetchSectionContext } from "../../B06_Section/B06_Section_Api_Fetch"; import { fetchSectionContext } from "../../B06_Section/B06_Section_Api_Fetch";
import { invalidateSectionDetail } from "../../B06_Section/B06_Section_Section_Store";
import { import {
onStaleStructureTypes, onStaleStructureTypes,
structureAnchorM, structureAnchorM,
@@ -270,18 +272,6 @@ export async function mountProfileView(
const panel = createRoutePanel( 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), onContourApply: (interval) => view3d.applyContours(interval),
...view3d.viewCallbacks, ...view3d.viewCallbacks,
onInputChange: markStale, 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 사용자 지시). * 화면 틀을 먼저 띄우고 자료가 끝나는 순서대로 채운다(2026-08-01 사용자 지시).
* 3D 지형은 **보조 자료라 로딩에 넣지 않는다**(2026-09-04 사용자 지시) — [3D] 모달을 처음 열 때 * 3D 지형은 **보조 자료라 로딩에 넣지 않는다**(2026-09-04 사용자 지시) — [3D] 모달을 처음 열 때
@@ -427,10 +427,7 @@ export async function mountProfileView(
const LOAD_STEP_COUNT = 3; const LOAD_STEP_COUNT = 3;
const mainContent = document.createElement("div"); const mainContent = document.createElement("div");
mainContent.className = "b05-route__main"; mainContent.className = "b05-route__main";
const planTools = document.createElement("div"); mainContent.append(view3d.modal.park);
planTools.className = "b05-route__plan-tools";
planTools.append(view3d.modal.button);
mainContent.append(planTools, view3d.modal.park);
center.append(mainContent); center.append(mainContent);
const progress = createProgressCircle({ label: "노선 정보를 불러오는 중…", overlay: true }); const progress = createProgressCircle({ label: "노선 정보를 불러오는 중…", overlay: true });
mainContent.append(progress.root); mainContent.append(progress.root);
@@ -481,6 +478,16 @@ export async function mountProfileView(
return; return;
} }
advanceLoading("종단면 자료를 불러오는 중…"); 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보다 먼저 채운다. // ③ 종단면·횡단 자료 — 하단 패널을 3D보다 먼저 채운다.
if (latestResponse.route) await restoreSections(latestResponse.route.id); if (latestResponse.route) await restoreSections(latestResponse.route.id);
+2 -6
View File
@@ -3,7 +3,7 @@
* 종단 보기(B05) 좌측 칸 재료 — 한 벌 좌측 패널(`design_shared`)에 종단 보기 몫을 넣는다. * 종단 보기(B05) 좌측 칸 재료 — 한 벌 좌측 패널(`design_shared`)에 종단 보기 몫을 넣는다.
* *
* 칸 정의는 `_Panel_Sections`(종단 보기 전용) · `design_sections`(두 보기 공용), 재료는 * 칸 정의는 `_Panel_Sections`(종단 보기 전용) · `design_sections`(두 보기 공용), 재료는
* `_Panel_Settings` · `_Panel_Route` · `_Panel_View`, 값 · 상수 · 입력 칸 만들기는 `_Panel_Fields` * `_Panel_Settings` · `_Panel_View`, 값 · 상수 · 입력 칸 만들기는 `_Panel_Fields`
* 에 있다(700줄 제한으로 나눔, 계획서 24-2b). 값을 읽고 되살리는 창구(`values` · `restore`)만 * 에 있다(700줄 제한으로 나눔, 계획서 24-2b). 값을 읽고 되살리는 창구(`values` · `restore`)만
* 여기 남는다. 아래 고정 영역(단추 넷) · 「구조물 배치」 폼은 한 벌이 두 보기에 같은 노드로 준다. * 여기 남는다. 아래 고정 영역(단추 넷) · 「구조물 배치」 폼은 한 벌이 두 보기에 같은 노드로 준다.
* ========================================================================== */ * ========================================================================== */
@@ -23,15 +23,12 @@ import {
parseOptional, parseOptional,
type RoutePanelValues, type RoutePanelValues,
} from "./B05_Profile_UI_Panel_Fields"; } from "./B05_Profile_UI_Panel_Fields";
import { createRoutePart } from "./B05_Profile_UI_Panel_Route";
import { createSettingsPart } from "./B05_Profile_UI_Panel_Settings"; import { createSettingsPart } from "./B05_Profile_UI_Panel_Settings";
import { createViewControls } from "./B05_Profile_UI_Panel_View"; import { createViewControls } from "./B05_Profile_UI_Panel_View";
export type { RoutePanelValues } from "./B05_Profile_UI_Panel_Fields"; export type { RoutePanelValues } from "./B05_Profile_UI_Panel_Fields";
interface PanelCallbacks { interface PanelCallbacks {
/** [계획노선 편집] — 큰 모달을 열어 노선을 고친다(계산은 모달 [확인]에서만 돈다). */
onEditPlannedRoute: () => void;
onContourApply: (interval: number) => void | Promise<void>; onContourApply: (interval: number) => void | Promise<void>;
onSurfaceVisible: (visible: boolean) => void; onSurfaceVisible: (visible: boolean) => void;
/** [예상형상] — 계획 코리도 서피스 + 클리핑 지형(공사 후) ↔ 원지반 완전체 전환. */ /** [예상형상] — 계획 코리도 서피스 + 클리핑 지형(공사 후) ↔ 원지반 완전체 전환. */
@@ -74,7 +71,6 @@ interface PanelCallbacks {
export function createRoutePanel(callbacks: PanelCallbacks, shared: DesignPageShared) { export function createRoutePanel(callbacks: PanelCallbacks, shared: DesignPageShared) {
const { viewControls, setCorridorPending } = createViewControls(callbacks); const { viewControls, setCorridorPending } = createViewControls(callbacks);
const settings = createSettingsPart(callbacks); const settings = createSettingsPart(callbacks);
const route = createRoutePart(callbacks);
const { const {
contourInterval, contourInterval,
gradeClass, gradeClass,
@@ -123,7 +119,7 @@ export function createRoutePanel(callbacks: PanelCallbacks, shared: DesignPageSh
// 좌측 패널 = 한 벌 등록부(`design_shared`) — 종단 보기 칸 재료만 넣는다. 아래 단추 넷 · 저장 · // 좌측 패널 = 한 벌 등록부(`design_shared`) — 종단 보기 칸 재료만 넣는다. 아래 단추 넷 · 저장 ·
// [확정]은 한 벌이 두 보기 몫을 모아 낸다. // [확정]은 한 벌이 두 보기 몫을 모아 낸다.
Object.assign(shared.parts, { settings, route }); Object.assign(shared.parts, { settings });
sharedPanel(shared); sharedPanel(shared);
return { return {
-26
View File
@@ -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<typeof createRoutePart>;
+5 -17
View File
@@ -1,16 +1,16 @@
/* ============================================================================= /* =============================================================================
* B05_Profile_UI_Panel_Sections.ts * B05_Profile_UI_Panel_Sections.ts
* 종단 보기 좌측 패널의 **칸 정의**(등록부 `registerPanelSection`) — 페이지 설정 · 계획노선. * 종단 보기 좌측 패널의 **칸 정의**(등록부 `registerPanelSection`) — 페이지 설정.
* 「구조물 배치」는 두 보기 공용이라 `design_sections.ts` 에 있다. * 「구조물 배치」는 두 보기 공용이라 `design_sections.ts` 에 있다.
* *
* 칸 차례(2026-08-18 사용자 확정): 구조물 배치 > 페이지 설정 > 계획노선 > 아래 고정 영역. * 칸 차례(2026-08-18 사용자 확정): 구조물 배치 > 페이지 설정 > 아래 고정 영역.
* 「선택 포인트 상세 설정」(3D 노선 점 옮김 · 지움 · 저장 안 되는 길)은 지움(345-2). 「경로 계산 설정」은 `Old/` 로 옮김(54-24). 재료는 `_Panel_Settings` · `_Panel_Route` 가 만들어 * 「선택 포인트 상세 설정」(3D 노선 점 옮김 · 지움 · 저장 안 되는 길)은 지움(345-2) ·
* `ctx.parts` 로 건넨다. * 「계획노선」 칸 · [계획노선 편집]은 지움(345-3 — 노선 편집은 가운데 위 본문).
* 「경로 계산 설정」은 `Old/` 로 옮김(54-24). 재료는 `_Panel_Settings` 가 만들어 `ctx.parts` 로 건넨다.
* ========================================================================== */ * ========================================================================== */
import "../A00_Common/design/design_sections"; import "../A00_Common/design/design_sections";
import { createSectionFrame, part, registerPanelSection } from "../A00_Common/design/design_panel"; 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"; import type { SettingsPart } from "./B05_Profile_UI_Panel_Settings";
registerPanelSection({ registerPanelSection({
@@ -24,15 +24,3 @@ registerPanelSection({
return frame.root; 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<RoutePart>(ctx, "route").plannedRouteButton);
return frame.root;
},
});
@@ -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 { createFreeView, type FreeChartGeom } from "../A00_Common/design/design_profile_free_view";
import { arrowsBelongToProfile } from "../B06_Section/B06_Section_UI_Cross_Panel_Base"; import { arrowsBelongToProfile } from "../B06_Section/B06_Section_UI_Cross_Panel_Base";
import { stepStructureStation } from "../common_util/common_util_structure_station"; import { stepStructureStation } from "../common_util/common_util_structure_station";
import { undoOwner } from "./B05_Profile_UI_RouteEdit_UndoOwner";
/** 방향키 ▲▼ 한 번에 움직이는 계획고(m) — 좌우(구조물 측점)는 1 m 걸음(234-1 · `stepStructureStation`). */ /** 방향키 ▲▼ 한 번에 움직이는 계획고(m) — 좌우(구조물 측점)는 1 m 걸음(234-1 · `stepStructureStation`). */
const KEY_STEP_M = 0.1; const KEY_STEP_M = 0.1;
@@ -320,6 +321,7 @@ export function createPanelTools(ctx: PanelToolsContext): PanelTools {
return; return;
} }
if (isCtrlKey(event, "z")) { if (isCtrlKey(event, "z")) {
if (undoOwner() === "plan") return; // 평면(노선 편집)을 마지막에 만졌다(345-3).
event.preventDefault(); event.preventDefault();
flushHold(); flushHold();
if (event.shiftKey) history.redo(); if (event.shiftKey) history.redo();
+51 -24
View File
@@ -1,10 +1,11 @@
/* ============================================================================= /* =============================================================================
* B05_Profile_UI_RouteEdit.ts * B05_Profile_UI_RouteEdit.ts
* 계획노선 편집 모달 — 예상노선(점선) 위에 계획노선(실선)을 고쳐 그린다. * 계획노선 편집(평면) — 예상노선(점선) 위에 계획노선(실선)을 고쳐 그린다.
* *
* 왜 모달인가(2026-09-06 사용자 확정) — [확인]을 누르면 배수유역부터 종·횡단·유토곡선까지 * 자리(345-3) — 모달이 아니라 종단 보기 가운데 위 본문(옛 3D 자리)이고 **페이지 수명**이다.
* 전 단계가 다시 도는 무거운 작업이다(용화 67측점 3분대). 신중히 하라는 뜻으로 큰 모달을 * 이력 · 키 듣기는 보기의 보임 · 숨김 · 떠남(`host`)을 따른다. [노선 반영]을 누르면 배수유역부터
* 쓰고, **편집 중에는 아무 계산도 나가지 않는다**. * 종·횡단·유토곡선까지 다시 돌고(무거움), 끝나면 부른 쪽이 새로고침 없이 페이지를 다시 세운다.
* **편집 중에는 아무 계산도 나가지 않는다**(띠 · 기울기 · 횡단 미리보기 묻기만).
* *
* 노선은 두 벌이다 — 예상노선(원본, 안 바뀜)과 계획노선(수정본, 사용자가 고침). * 노선은 두 벌이다 — 예상노선(원본, 안 바뀜)과 계획노선(수정본, 사용자가 고침).
* [초기화]는 마지막에 적용한 초기노선 갈래로 편집값만 되돌린다(계획서 20 · `_Initial`). * [초기화]는 마지막에 적용한 초기노선 갈래로 편집값만 되돌린다(계획서 20 · `_Initial`).
@@ -82,6 +83,7 @@ import {
type RouteEditHistory, type RouteEditHistory,
type RouteEditSnapshot, type RouteEditSnapshot,
} from "./B05_Profile_UI_RouteEdit_History"; } from "./B05_Profile_UI_RouteEdit_History";
import type { DesignPageHost } from "../A00_Common/design/design_page";
import "./B05_Profile_UI_Style_RouteEdit.css"; import "./B05_Profile_UI_Style_RouteEdit.css";
/** 등고선 눈금을 고를 때 쓸 라벨 예산 — B04 지도 기본값(350)보다 **넉넉히** 둔다 /** 등고선 눈금을 고를 때 쓸 라벨 예산 — B04 지도 기본값(350)보다 **넉넉히** 둔다
@@ -91,7 +93,7 @@ const CONTOUR_LEVEL_BUDGET = 1200;
type Vertex = [number, number]; type Vertex = [number, number];
/** 모달을 연다. [확인]이 끝나면 `onApplied`를 부른다(화면 다시 읽기). */ /** 편집기 곁값. [노선 반영]이 끝나면 `onApplied`를 부른다(화면 다시 세우기). */
export interface RouteEditOptions { export interface RouteEditOptions {
/** 규칙 측점 간격(m) — 좌측 패널이 쥔 값을 그대로 받는다(코드에 굳히지 않는다). */ /** 규칙 측점 간격(m) — 좌측 패널이 쥔 값을 그대로 받는다(코드에 굳히지 않는다). */
stationIntervalM?: number; stationIntervalM?: number;
@@ -100,15 +102,23 @@ export interface RouteEditOptions {
/** 등고선 간격(m)·평활 여부 — 3D 뷰어가 쓰는 값 그대로. */ /** 등고선 간격(m)·평활 여부 — 3D 뷰어가 쓰는 값 그대로. */
contourIntervalM?: number; contourIntervalM?: number;
smooth?: boolean; smooth?: boolean;
/** 머리 도구줄 끝에 앉힐 부른 쪽 단추([3D] 등). */
tools?: HTMLElement[];
} }
export async function openRouteEditModal( /** 편집기가 사는 보기 — 보임 · 숨김 · 떠남. */
export type RouteEditHost = Pick<DesignPageHost, "visible" | "onShow" | "onHide" | "onDispose">;
/** 편집기를 `parent` 에 세운다 — 자료를 읽고 나면 끝난다(떠날 때 정리는 `host.onDispose`). */
export async function mountRouteEditor(
parent: HTMLElement,
host: RouteEditHost,
projectId: string, projectId: string,
onApplied: () => void | Promise<void>, onApplied: () => void | Promise<void>,
options: RouteEditOptions = {}, options: RouteEditOptions = {},
): Promise<void> { ): Promise<void> {
const stationIntervalM = options.stationIntervalM ?? 20; const stationIntervalM = options.stationIntervalM ?? 20;
const chrome = createRouteEditChrome(); const chrome = createRouteEditChrome(parent, options.tools);
const { overlay, canvas, status, busy, measureBox, measureText, measureButton } = chrome; const { overlay, canvas, status, busy, measureBox, measureText, measureButton } = chrome;
const { minorContourButton, terrainSelect, stationSnapButton } = chrome; const { minorContourButton, terrainSelect, stationSnapButton } = chrome;
const context = canvas.getContext("2d")!; const context = canvas.getContext("2d")!;
@@ -248,12 +258,13 @@ export async function openRouteEditModal(
meta && viewReady ? captureView(meta, view, rotation.radians()) : null, meta && viewReady ? captureView(meta, view, rotation.radians()) : null,
); );
const close = (): void => { /** 떠날 때 — 페이지가 버린다(345-3 · 닫기 단추 없음). */
const dispose = (): void => {
viewMemory.flush(); viewMemory.flush();
closed = true; closed = true;
window.removeEventListener("resize", resize); sizeWatch.disconnect();
historyControls.dispose(); // 단축키는 창(window)에 달려 있어 안 떼면 닫힌 뒤에도 산다. historyControls.dispose(); // 단축키는 창(window)에 달려 있어 안 떼면 떠난 뒤에도 산다.
curveLabelBox.destroy(); // 패널은 `document.body` 에 붙어 있어 스스로 안 사라진다. curveLabelBox.destroy();
overlay.remove(); overlay.remove();
}; };
/** 초기노선 갈래 — 로직 단추 · [초기화] · 비교값(계획서 20). 서버 묻기는 `_Initial` 몫. */ /** 초기노선 갈래 — 로직 단추 · [초기화] · 비교값(계획서 20). 서버 묻기는 `_Initial` 몫. */
@@ -279,22 +290,36 @@ export async function openRouteEditModal(
criteria: initial.current().criteria, 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", () => {
overlay.querySelector('[data-act="cancel"]')!.addEventListener("click", cancel); const snapshot = history?.reset();
// 배경 클릭으로 닫지 않는다 — 고치던 노선을 실수로 날리지 않게. if (!snapshot) return showToast("반영 뒤 고친 노선이 없습니다.", "info");
initial.restoreTag(snapshot.rec);
restoreSnapshot(snapshot, "반영 전 노선으로 되돌렸습니다.", false);
});
function resize(): void { function resize(): void {
if (closed) return; if (closed || !overlay.isConnected) return;
// 크기가 바뀌어도 가운데 자리 · 배율을 지킨다 — px 이동량을 그대로 두면 지도가 밀려 // 크기가 바뀌어도 가운데 자리 · 배율을 지킨다 — px 이동량을 그대로 두면 지도가 밀려
// 노선이 화면 밖으로 나가고, 그 자리가 보던 화면으로 저장돼 다시 열어도 빈 화면이 된다. // 노선이 화면 밖으로 나가고, 그 자리가 보던 화면으로 저장돼 다시 열어도 빈 화면이 된다.
const kept = meta && viewReady ? captureView(meta, view, rotation.radians()) : null; const kept = meta && viewReady ? captureView(meta, view, rotation.radians()) : null;
const { width, height } = fitCanvas(canvas, context); const { width, height } = fitCanvas(canvas, context);
view = { ...view, width, height, mapRect: computeMapRect(meta, width, height) }; view = { ...view, width, height, mapRect: computeMapRect(meta, width, height) };
if (meta && kept) view = { ...view, ...restoreSavedView(meta, view, kept) }; if (meta && kept) view = { ...view, ...restoreSavedView(meta, view, kept) };
if (!viewReady) openView(); // 숨은 채 섰으면(크기 0) 처음 보일 때 맞춘다.
draw(); draw();
} }
window.addEventListener("resize", resize); // 칸 크기를 따라감 — 창 크기 · 아래 종단 판 끌기 · 좌측 접기 모두(345-3).
const sizeWatch = new ResizeObserver(() => resize());
sizeWatch.observe(overlay.querySelector<HTMLElement>(".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] => const toScreen = (vertex: Vertex): [number, number] =>
meta ? metricToScreen(meta, view, vertex[0], vertex[1]) : [0, 0]; meta ? metricToScreen(meta, view, vertex[0], vertex[1]) : [0, 0];
@@ -401,6 +426,7 @@ export async function openRouteEditModal(
// ── 곡선 라벨 — 고른 꺾임점 옆에 뜨는 조작 패널. 배선은 `_CurveBar` 몫 ── // ── 곡선 라벨 — 고른 꺾임점 옆에 뜨는 조작 패널. 배선은 `_CurveBar` 몫 ──
const curveBar = createCurveBar({ const curveBar = createCurveBar({
canvas, canvas,
container: overlay,
state: () => ({ state: () => ({
picked, picked,
planned, planned,
@@ -566,10 +592,15 @@ export async function openRouteEditModal(
stationSnap: () => stationSnap, stationSnap: () => stationSnap,
initial, initial,
profileShown: strip.settle, profileShown: strip.settle,
close,
onApplied, 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 { try {
const [plan, drainage, sources] = await Promise.all([ const [plan, drainage, sources] = await Promise.all([
@@ -654,12 +685,8 @@ export async function openRouteEditModal(
draw(); draw();
}); });
}); });
resize(); viewWanted = true;
// 보던 화면이 있으면 그대로 연다(계획서 18 ③). 없으면 노선 전체에 맞춘다. resize(); // 칸 크기가 서 있으면 여기서 보던 화면을 연다(아니면 처음 보일 때).
const opening = openingView(projectId, meta, view, planned);
view = opening.view;
rotation.set(opening.rotationRad);
viewReady = true;
showStatus(); showStatus();
draw(); draw();
strip.refresh(); strip.refresh();
@@ -1,9 +1,10 @@
/* ============================================================================= /* =============================================================================
* B05_Profile_UI_RouteEdit_Apply.ts * B05_Profile_UI_RouteEdit_Apply.ts
* 계획노선 편집 모달의 **[확인]** — 무거운 재계산과 대기 표시. * 계획노선 편집(평면)의 **[노선 반영]**(옛 [확인] · 346-3 까지 남김) — 무거운 재계산과 대기 표시.
* *
* 다시 계산은 **노선이 바뀐 뒤 [확인]에서만** 돈다(계획서 20 ④). 노선이 창을 연 그대로면 * 다시 계산은 **노선이 바뀐 뒤 [노선 반영]에서만** 돈다(계획서 20 ④). 노선이 마지막 반영 그대로면
* 계산 없이 닫는다. [초기화](옛 「예상노선으로」)는 편집값만 되돌린다(`_Initial`). * 계산하지 않는다. [초기화](옛 「예상노선으로」)는 편집값만 되돌린다(`_Initial`). 끝나면 부른 쪽이
* 페이지를 새로고침 없이 다시 세운다(`onApplied`).
* *
* 누르면 서버가 배수유역부터 종·횡단·유토곡선까지 전 단계를 다시 돈다(약 90초). 중간 취소는 * 누르면 서버가 배수유역부터 종·횡단·유토곡선까지 전 단계를 다시 돈다(약 90초). 중간 취소는
* 만들지 않기로 했으므로(계획서 0-2, 2026-09-09) **얼마나 지났는지**를 초로 보여 사람이 * 만들지 않기로 했으므로(계획서 0-2, 2026-09-09) **얼마나 지났는지**를 초로 보여 사람이
@@ -23,7 +24,7 @@ export interface RouteApplyParams {
projectId: string; projectId: string;
/** 지금 편집값 — 누른 순간에 읽는다. */ /** 지금 편집값 — 누른 순간에 읽는다. */
nodes: () => { planned: Vertex[]; curveOn: boolean[]; curveRadius: Array<number | null> }; nodes: () => { planned: Vertex[]; curveOn: boolean[]; curveRadius: Array<number | null> };
/** 창을 연 뒤 노선이 바뀌었나 — 안 바뀌었으면 다시 계산하지 않는다. */ /** 마지막 반영 뒤 노선이 바뀌었나 — 안 바뀌었으면 다시 계산하지 않는다. */
changed: () => boolean; changed: () => boolean;
/** 설계 지형으로 보낼 분석 지형(PLAN 133-2) — 고르기가 안 보이면 null(지금 설계 지형 그대로). */ /** 설계 지형으로 보낼 분석 지형(PLAN 133-2) — 고르기가 안 보이면 null(지금 설계 지형 그대로). */
terrain: () => string | null; terrain: () => string | null;
@@ -36,12 +37,11 @@ export interface RouteApplyParams {
}; };
/** 종단 띠 — 응답을 기다려 띠에 보인 계획선(균형 종단 · 지반고)을 그대로 적용한다(PLAN 75-1). */ /** 종단 띠 — 응답을 기다려 띠에 보인 계획선(균형 종단 · 지반고)을 그대로 적용한다(PLAN 75-1). */
profileShown: () => Promise<boolean>; profileShown: () => Promise<boolean>;
/** 성공하면 모달을 닫고 화면을 다시 읽는다. */ /** 성공하면 화면을 다시 세운다(대기 막은 다시 세울 때까지 남음). */
close: () => void;
onApplied: () => void | Promise<void>; onApplied: () => void | Promise<void>;
} }
/** [확인]을 붙인다. 리스너는 모달과 수명이 같다. */ /** [노선 반영]을 붙인다. 리스너는 편집기와 수명이 같다. */
export function bindRouteApply(params: RouteApplyParams): void { export function bindRouteApply(params: RouteApplyParams): void {
const { overlay, busy, projectId } = params; const { overlay, busy, projectId } = params;
@@ -64,7 +64,6 @@ export function bindRouteApply(params: RouteApplyParams): void {
clearResults(projectId); clearResults(projectId);
params.initial.keep(); // 초기값은 새 노선의 출발점 — 옛 노선 결과와 함께 버리지 않는다. params.initial.keep(); // 초기값은 새 노선의 출발점 — 옛 노선 결과와 함께 버리지 않는다.
showToast("노선을 다시 계산했습니다.", "success"); showToast("노선을 다시 계산했습니다.", "success");
params.close();
await params.onApplied(); await params.onApplied();
} catch (error) { } catch (error) {
busy.hidden = true; busy.hidden = true;
@@ -82,7 +81,6 @@ export function bindRouteApply(params: RouteApplyParams): void {
} }
if (!params.changed()) { if (!params.changed()) {
showToast("노선이 그대로라 다시 계산하지 않았습니다.", "info"); showToast("노선이 그대로라 다시 계산하지 않았습니다.", "info");
params.close();
return; return;
} }
void runHeavy("계획노선 반영", async () => void runHeavy("계획노선 반영", async () =>
+34 -20
View File
@@ -1,9 +1,8 @@
import { createButton } from "@ui/ui_template_elements"; import { createButton } from "@ui/ui_template_elements";
import "@ui/ui_template_modal.css";
/* ============================================================================= /* =============================================================================
* B05_Profile_UI_RouteEdit_Chrome.ts * B05_Profile_UI_RouteEdit_Chrome.ts
* 계획노선 편집 모달의 **뼈대** — 창·단추·오버레이 판을 만들고 자주 쓰는 요소를 집어 준다. * 계획노선 편집(평면)의 **뼈대** — 칸·단추·오버레이 판을 만들고 자주 쓰는 요소를 집어 준다.
* *
* `B05_Profile_UI_RouteEdit.ts` 가 700줄을 넘겨 떼어낸 조각이다(2026-09-12). 생김새만 있고 * `B05_Profile_UI_RouteEdit.ts` 가 700줄을 넘겨 떼어낸 조각이다(2026-09-12). 생김새만 있고
* 동작은 없다 — 배선은 본체와 각 조각(`_Apply`·`_Rotate`·`_History`…)이 한다. * 동작은 없다 — 배선은 본체와 각 조각(`_Apply`·`_Rotate`·`_History`…)이 한다.
@@ -12,8 +11,9 @@ import "@ui/ui_template_modal.css";
* 단추는 제목행 오른쪽, 조작 설명은 지도 왼쪽 위 2열, 상태·범례는 왼쪽 아래, 잰 값은 오른쪽 * 단추는 제목행 오른쪽, 조작 설명은 지도 왼쪽 위 2열, 상태·범례는 왼쪽 아래, 잰 값은 오른쪽
* 아래. 메인 창은 왼쪽으로 밀고 오른쪽 세로 칸에 횡단 두 판이 앉는다. * 아래. 메인 창은 왼쪽으로 밀고 오른쪽 세로 칸에 횡단 두 판이 앉는다.
* *
* 겉틀 — 배경 · 제목줄은 공용 모달 조각(`ui-modal__backdrop` · `ui-modal__top`). `openModal` 은 * 자리(345-3) — 모달이 아니라 종단 보기 가운데 위 본문(옛 3D 자리)에 붙는다. 덮개 · 전체 화면 ·
* 안 씀 — 바깥 누름 · Esc 로 닫혀 고치던 노선을 날리고, 제목줄에 단추 묶음 · 오른쪽 횡단 칸이 못 앉음. * 닫기 단추는 걷었다 · [취소] → [반영 전으로] · [확인] → [노선 반영] · 오른쪽 횡단 칸은 접는 칸.
* 머리 끝 `tools` 자리에 부른 쪽 단추([3D])가 앉는다.
* ========================================================================== */ * ========================================================================== */
/** 회전 단추 아이콘 — **반만 도는 화살표**(2026-09-12 사용자 지시 ㉙). 한 바퀴를 다 그린 /** 회전 단추 아이콘 — **반만 도는 화살표**(2026-09-12 사용자 지시 ㉙). 한 바퀴를 다 그린
@@ -42,16 +42,20 @@ export interface RouteEditChrome {
terrainSelect: HTMLSelectElement; terrainSelect: HTMLSelectElement;
} }
/** 모달을 만들어 `document.body` 에 붙이고, 자주 쓰는 요소를 집어 돌려준다. */ /** 뼈대를 만들어 `parent` 에 붙이고, 자주 쓰는 요소를 집어 돌려준다. */
export function createRouteEditChrome(): RouteEditChrome { export function createRouteEditChrome(
parent: HTMLElement,
tools: HTMLElement[] = [],
): RouteEditChrome {
const overlay = document.createElement("div"); const overlay = document.createElement("div");
overlay.className = "ui-modal__backdrop b05-routeedit"; overlay.className = "b05-routeedit";
overlay.innerHTML = ` overlay.innerHTML = `
<div class="b05-routeedit__box ui-popup" role="dialog" aria-label="계획노선 편집"> <div class="b05-routeedit__box" aria-label="계획노선 편집">
<div class="ui-modal__top b05-routeedit__head"> <div class="b05-routeedit__head">
<h3>계획노선 편집</h3> <h3>계획노선</h3>
<span class="b05-routeedit__rec-now" data-rec-now></span> <span class="b05-routeedit__rec-now" data-rec-now></span>
<span class="b05-routeedit__actions"> <span class="b05-routeedit__actions">
<span data-slot="tools"></span>
<button type="button" class="ui-btn ui-btn--ghost" data-act="recommend" disabled <button type="button" class="ui-btn ui-btn--ghost" data-act="recommend" disabled
title="예상노선 추적 · 영선 · 토공 최소 · 기울기 최소 · 비중 계산 중 하나로 노선을 추천받아 미리 봅니다">노선 추천</button> title="예상노선 추적 · 영선 · 토공 최소 · 기울기 최소 · 비중 계산 중 하나로 노선을 추천받아 미리 봅니다">노선 추천</button>
<i class="b05-routeedit__divider" aria-hidden="true"></i> <i class="b05-routeedit__divider" aria-hidden="true"></i>
@@ -76,12 +80,12 @@ export function createRouteEditChrome(): RouteEditChrome {
<button type="button" class="ui-btn ui-btn--ghost" data-act="redo" <button type="button" class="ui-btn ui-btn--ghost" data-act="redo"
title="다시하기 (Ctrl+Y)" disabled>↷ 다시하기</button> title="다시하기 (Ctrl+Y)" disabled>↷ 다시하기</button>
<button type="button" class="ui-btn ui-btn--ghost" data-act="reset" disabled <button type="button" class="ui-btn ui-btn--ghost" data-act="reset" disabled
title="전처리 때 만든 초기값 노선(예상노선 추적)으로 되돌립니다 — 다시 계산은 [확인]에서">초기화</button> title="전처리 때 만든 초기값 노선(예상노선 추적)으로 되돌립니다 — 다시 계산은 [노선 반영]에서">초기화</button>
<button type="button" class="ui-btn ui-btn--ghost" data-act="cancel">취소</button> <button type="button" class="ui-btn ui-btn--ghost" data-act="cancel"
title="마지막 [노선 반영] 뒤 고친 노선을 버리고 그때 노선으로 되돌립니다 — 되돌리기로 다시 살릴 수 있습니다">반영 전으로</button>
<button type="button" class="ui-btn ui-btn--filled" data-act="apply" <button type="button" class="ui-btn ui-btn--filled" data-act="apply"
title="이 노선으로 배수유역부터 다시 계산합니다 — 결과가 새 초기값이 되고 종횡단 · 구조물 · 관 옵션 편집은 새 노선 기본값으로 바뀝니다">확인</button> title="이 노선으로 배수유역부터 다시 계산합니다 — 결과가 새 초기값이 되고 종횡단 · 구조물 · 관 옵션 편집은 새 노선 기본값으로 바뀝니다">노선 반영</button>
</span> </span>
<span data-slot="close"></span>
</div> </div>
<div class="b05-routeedit__canvas-wrap"> <div class="b05-routeedit__canvas-wrap">
<canvas class="b05-routeedit__canvas"></canvas> <canvas class="b05-routeedit__canvas"></canvas>
@@ -117,13 +121,23 @@ export function createRouteEditChrome(): RouteEditChrome {
</div> </div>
<div class="b05-routeedit__busy" hidden><span></span></div> <div class="b05-routeedit__busy" hidden><span></span></div>
</div> </div>
<div class="b05-routeedit__side"></div>`; <aside class="b05-routeedit__side-wrap">
document.body.append(overlay); <button type="button" class="ui-btn ui-btn--ghost b05-routeedit__side-toggle"
data-act="side-toggle" aria-expanded="true" title="오른쪽 횡단 미리보기 칸 접기 · 펴기">횡단</button>
<div class="b05-routeedit__side"></div>
</aside>`;
parent.append(overlay);
overlay.querySelector('[data-slot="tools"]')!.replaceWith(...tools);
const closeButton = createButton({ label: "✕", variant: "ghost" }); // 횡단 미리보기 = 접는 칸 — 좁은 화면은 접힌 채 시작(지도가 먼저다).
closeButton.classList.add("b05-routeedit__close"); const sideWrap = overlay.querySelector<HTMLElement>(".b05-routeedit__side-wrap")!;
closeButton.setAttribute("aria-label", "닫기"); const sideToggle = overlay.querySelector<HTMLButtonElement>('[data-act="side-toggle"]')!;
overlay.querySelector('[data-slot="close"]')!.replaceWith(closeButton); const setSide = (open: boolean): void => {
sideWrap.classList.toggle("is-collapsed", !open);
sideToggle.setAttribute("aria-expanded", String(open));
};
setSide(window.innerWidth >= 1500);
sideToggle.addEventListener("click", () => setSide(sideWrap.classList.contains("is-collapsed")));
const measureCloseButton = createButton({ label: "✕", variant: "ghost" }); const measureCloseButton = createButton({ label: "✕", variant: "ghost" });
measureCloseButton.classList.add("b05-routeedit__measure-close"); measureCloseButton.classList.add("b05-routeedit__measure-close");
@@ -42,6 +42,8 @@ export interface CurveBarState {
export interface CurveBarParams { export interface CurveBarParams {
canvas: HTMLCanvasElement; canvas: HTMLCanvasElement;
/** 패널이 앉을 칸(편집기 뿌리 · 345-3). */
container: HTMLElement;
state: () => CurveBarState; state: () => CurveBarState;
/** 그리기 좌표로 옮긴다. 돌린 지도에서는 **돌린 뒤 자리**를 줘야 패널이 노드 옆에 붙는다. */ /** 그리기 좌표로 옮긴다. 돌린 지도에서는 **돌린 뒤 자리**를 줘야 패널이 노드 옆에 붙는다. */
toScreen: (vertex: Vertex) => [number, number]; toScreen: (vertex: Vertex) => [number, number];
@@ -58,62 +60,67 @@ export interface CurveBar {
} }
export function createCurveBar(params: CurveBarParams): CurveBar { export function createCurveBar(params: CurveBarParams): CurveBar {
const label = createCurveLabel({ const label = createCurveLabel(
onClose: () => params.onUnselect(), {
onRadius: (value) => { onClose: () => params.onUnselect(),
const { picked, curveRadius } = params.state(); onRadius: (value) => {
if (picked < 0) return; const { picked, curveRadius } = params.state();
curveRadius[picked] = value; if (picked < 0) return;
// 반지름만 바꾼 것이라 노드 자리는 그대로지만, 그려진 선은 낡았다. curveRadius[picked] = value;
params.applyEdit(value === null ? "반지름을 자동으로 되돌렸습니다." : "반지름을 바꿨습니다."); // 반지름만 바꾼 것이라 노드 자리는 그대로지만, 그려진 선은 낡았다.
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"
? "곡선 길이를 고정했습니다."
: "고정을 풀었습니다.",
);
},
onApexLock: (locked) => {
const { picked, apexLock, planned } = params.state();
if (picked < 0) return;
apexLock[picked] = locked;
const name = picked === 0 ? "시점" : picked === planned.length - 1 ? "종점" : "교각점";
// 자리는 안 바뀌었지만 잡히는 것이 달라졌다 — 상태줄과 되돌리기에 한 걸음으로 남긴다.
params.applyEdit(locked ? `${name}을 고정했습니다.` : `${name} 고정을 풀었습니다.`);
},
onCurveOn: (on) => {
const { picked, curveOn } = params.state();
if (picked < 0) return;
curveOn[picked] = on;
params.applyEdit(on ? "곡선을 넣었습니다." : "곡선을 지웠습니다.");
},
}, },
onArcLength: (value) => { params.container,
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"
? "곡선 길이를 고정했습니다."
: "고정을 풀었습니다.",
);
},
onApexLock: (locked) => {
const { picked, apexLock, planned } = params.state();
if (picked < 0) return;
apexLock[picked] = locked;
const name = picked === 0 ? "시점" : picked === planned.length - 1 ? "종점" : "교각점";
// 자리는 안 바뀌었지만 잡히는 것이 달라졌다 — 상태줄과 되돌리기에 한 걸음으로 남긴다.
params.applyEdit(locked ? `${name}을 고정했습니다.` : `${name} 고정을 풀었습니다.`);
},
onCurveOn: (on) => {
const { picked, curveOn } = params.state();
if (picked < 0) return;
curveOn[picked] = on;
params.applyEdit(on ? "곡선을 넣었습니다." : "곡선을 지웠습니다.");
},
});
/** 고른 자리에 맞춰 라벨을 옮겨 그린다. 끝점은 곡선이 없어 **고정 단추만** 낸다(계획서 22-5). */ /** 고른 자리에 맞춰 라벨을 옮겨 그린다. 끝점은 곡선이 없어 **고정 단추만** 낸다(계획서 22-5). */
function sync(): void { function sync(): void {
@@ -169,7 +176,7 @@ export function createCurveBar(params: CurveBarParams): CurveBar {
const [screenX, screenY] = params.toScreen(planned[picked]); const [screenX, screenY] = params.toScreen(planned[picked]);
label.show({ label.show({
seat: picked, seat: picked,
// 패널은 `position: fixed` 라 **화면 좌표**로 넘긴다. // 패널 자리는 **화면 좌표**로 넘긴다(칸 안 좌표로는 `_Label` 이 바꿔 적음).
at: [screenX + rect.left, screenY + rect.top], at: [screenX + rect.left, screenY + rect.top],
// 넘어가도 되는 테두리 = **지도 칸**(하단 정보행 위까지). 밖으로 나가면 지금 무엇을 // 넘어가도 되는 테두리 = **지도 칸**(하단 정보행 위까지). 밖으로 나가면 지금 무엇을
// 고치는지 모달 안에서 안 보인다(2026-09-12 사용자 지적 ⑨). // 고치는지 모달 안에서 안 보인다(2026-09-12 사용자 지적 ⑨).
@@ -11,6 +11,7 @@
* ========================================================================== */ * ========================================================================== */
import { keyLetter } from "../A00_Common/key_shortcut"; import { keyLetter } from "../A00_Common/key_shortcut";
import { setPlanUndoListening, undoOwner } from "./B05_Profile_UI_RouteEdit_UndoOwner";
export type Vertex = [number, number]; export type Vertex = [number, number];
@@ -126,10 +127,12 @@ export interface HistoryControlsParams {
/** [되돌리기]·[다시하기] 단추와 단축키(Ctrl+Z / Ctrl+Y / Ctrl+Shift+Z)를 붙인다. /** [되돌리기]·[다시하기] 단추와 단축키(Ctrl+Z / Ctrl+Y / Ctrl+Shift+Z)를 붙인다.
* *
* 돌려주는 `sync` 를 편집이 끝날 때마다 부르면 단추 켜짐이 맞춰진다. `dispose` 는 창을 * 돌려주는 `sync` 를 편집이 끝날 때마다 부르면 단추 켜짐이 맞춰진다. 단축키는 `listen(true)` 부터
* 닫을 때 부른다 — 단축키를 창(window)에 달았기 때문에 안 떼면 닫힌 뒤에도 살아 있다. */ * 듣는다(보기가 보일 때 · 345-3) — 창(window)에 달았기 때문에 숨을 때 · 떠날 때 `listen(false)`.
* 종단도도 같은 키를 들어 **마지막에 만진 판**만 받는다(`_UndoOwner`). */
export function bindHistoryControls(params: HistoryControlsParams): { export function bindHistoryControls(params: HistoryControlsParams): {
sync: () => void; sync: () => void;
listen: (on: boolean) => void;
dispose: () => void; dispose: () => void;
} { } {
const { overlay, getHistory, restore } = params; const { overlay, getHistory, restore } = params;
@@ -155,14 +158,18 @@ export function bindHistoryControls(params: HistoryControlsParams): {
redoBtn.addEventListener("click", () => step("redo")); redoBtn.addEventListener("click", () => step("redo"));
const onKey = (event: KeyboardEvent): void => { const onKey = (event: KeyboardEvent): void => {
if (!(event.ctrlKey || event.metaKey)) return; if (!(event.ctrlKey || event.metaKey) || undoOwner() !== "plan") return;
const key = keyLetter(event); const key = keyLetter(event);
if (key === "z" && !event.shiftKey) step("undo"); if (key === "z" && !event.shiftKey) step("undo");
else if (key === "y" || (key === "z" && event.shiftKey)) step("redo"); else if (key === "y" || (key === "z" && event.shiftKey)) step("redo");
else return; else return;
event.preventDefault(); event.preventDefault();
}; };
window.addEventListener("keydown", onKey); const listen = (on: boolean): void => {
window.removeEventListener("keydown", onKey);
if (on) window.addEventListener("keydown", onKey);
setPlanUndoListening(on);
};
return { sync, dispose: () => window.removeEventListener("keydown", onKey) }; return { sync, listen, dispose: () => listen(false) };
} }
+19 -16
View File
@@ -10,8 +10,8 @@
* **고정 단추**로 정한다(둘 다 붙들 수는 없다 — `_Edits.ts` 설명 참고). * **고정 단추**로 정한다(둘 다 붙들 수는 없다 — `_Edits.ts` 설명 참고).
* *
* **자리**(2026-09-07 사용자 지시) * **자리**(2026-09-07 사용자 지시)
* · 몸통은 `document.body` 에 `position: fixed` 로 띄운다 — 모달이 `overflow: hidden` 이라 * · 몸통은 편집 칸(`container`) 안에 `position: absolute` 로 띄운다(345-3 · 본문 평면) — 셈은
* 안에 두면 가장자리에서 **잘린다**. * 화면(viewport) 좌표로 하고 적을 때만 칸 왼쪽 위만큼 뺀다.
* · 다만 **지도 칸 밖으로는 안 나간다**(2026-09-12 사용자 지적 ⑨) — 상자 밖이나 하단 * · 다만 **지도 칸 밖으로는 안 나간다**(2026-09-12 사용자 지적 ⑨) — 상자 밖이나 하단
* 정보행 위로 넘어가면 지금 무엇을 고치는지 모달 안에서 안 보인다. 「잘리지 않게」와 * 정보행 위로 넘어가면 지금 무엇을 고치는지 모달 안에서 안 보인다. 「잘리지 않게」와
* 「상자 밖으로 나가게」는 다른 문제여서 자리 계산에서만 가둔다. * 「상자 밖으로 나가게」는 다른 문제여서 자리 계산에서만 가둔다.
@@ -97,7 +97,7 @@ export interface CurveLabelHandlers {
export interface CurveLabel { export interface CurveLabel {
show: (state: CurveLabelState) => void; show: (state: CurveLabelState) => void;
hide: () => void; hide: () => void;
/** 창을 닫을 때 — 몸통이 `document.body` 에 붙어 있어 스스로 안 사라진다. */ /** 편집기를 버릴 때 — 몸통을 칸에서 뗀다. */
destroy: () => void; destroy: () => void;
} }
@@ -121,8 +121,11 @@ function unitLabel(text: string): HTMLSpanElement {
return span; return span;
} }
/** 패널을 만든다. 몸통은 `document.body` 에 붙어 모달 밖으로도 넘어간다. */ /** 패널을 만든다. 몸통은 `container`(편집 칸 · position 이 있는 칸) 안에 붙는다. */
export function createCurveLabel(handlers: CurveLabelHandlers): CurveLabel { export function createCurveLabel(
handlers: CurveLabelHandlers,
container: HTMLElement = document.body,
): CurveLabel {
const root = document.createElement("div"); const root = document.createElement("div");
root.className = "b05-routeedit__label"; root.className = "b05-routeedit__label";
root.hidden = true; root.hidden = true;
@@ -138,7 +141,13 @@ export function createCurveLabel(handlers: CurveLabelHandlers): CurveLabel {
<span data-slot="lock-apex"></span> <span data-slot="lock-apex"></span>
</div> </div>
<span class="b05-routeedit__curve-info"></span>`; <span class="b05-routeedit__curve-info"></span>`;
document.body.append(root); container.append(root);
/** 화면 좌표 → 칸 안 좌표로 적는다. */
const moveTo = (left: number, top: number): void => {
const origin = container.getBoundingClientRect();
root.style.left = `${Math.round(left - origin.left)}px`;
root.style.top = `${Math.round(top - origin.top)}px`;
};
const head = root.querySelector<HTMLElement>(".b05-routeedit__label-head")!; const head = root.querySelector<HTMLElement>(".b05-routeedit__label-head")!;
const seatText = root.querySelector<HTMLElement>(".b05-routeedit__curve-label")!; const seatText = root.querySelector<HTMLElement>(".b05-routeedit__curve-label")!;
@@ -229,12 +238,8 @@ export function createCurveLabel(handlers: CurveLabelHandlers): CurveLabel {
let dragFrom: { x: number; y: number; left: number; top: number } | null = null; let dragFrom: { x: number; y: number; left: number; top: number } | null = null;
head.addEventListener("pointerdown", (event) => { head.addEventListener("pointerdown", (event) => {
if ((event.target as HTMLElement).closest("button")) return; // 단추는 단추대로. if ((event.target as HTMLElement).closest("button")) return; // 단추는 단추대로.
dragFrom = { const box = root.getBoundingClientRect();
x: event.clientX, dragFrom = { x: event.clientX, y: event.clientY, left: box.left, top: box.top };
y: event.clientY,
left: root.offsetLeft,
top: root.offsetTop,
};
head.setPointerCapture(event.pointerId); head.setPointerCapture(event.pointerId);
event.preventDefault(); event.preventDefault();
}); });
@@ -248,8 +253,7 @@ export function createCurveLabel(handlers: CurveLabelHandlers): CurveLabel {
root.offsetHeight, root.offsetHeight,
limit, limit,
); );
root.style.left = `${Math.round(left)}px`; moveTo(left, top);
root.style.top = `${Math.round(top)}px`;
// 꺾임점 기준으로 기억한다 — 지도를 옮기거나 확대해도 같은 자리에 따라온다. // 꺾임점 기준으로 기억한다 — 지도를 옮기거나 확대해도 같은 자리에 따라온다.
manual = [left - anchor[0], top - anchor[1]]; manual = [left - anchor[0], top - anchor[1]];
}); });
@@ -278,8 +282,7 @@ export function createCurveLabel(handlers: CurveLabelHandlers): CurveLabel {
height, height,
state.bounds, state.bounds,
); );
root.style.left = `${Math.round(left)}px`; moveTo(left, top);
root.style.top = `${Math.round(top)}px`;
} }
/** 테두리 안으로 민다. 패널이 테두리보다 크면 왼쪽·위를 맞춰 **머리가 먼저 보이게** 한다. */ /** 테두리 안으로 민다. 패널이 테두리보다 크면 왼쪽·위를 맞춰 **머리가 먼저 보이게** 한다. */
@@ -0,0 +1,33 @@
/* =============================================================================
* B05_Profile_UI_RouteEdit_UndoOwner.ts
* 한 화면 두 이력(345-3) — 평면(노선 편집)과 종단도가 둘 다 창(window)에서 Ctrl+Z · Y 를 듣는다.
* **마지막에 만진 판 하나만** 받는다: 누른 자리(평면 `.b05-routeedit` · 종단 판 `.b05-route-profile`)를
* 기억해 두고, 각 듣는 쪽이 받기 전에 묻는다. 평면이 안 듣는 동안(보기 숨김 · 떠남)은 늘 종단도.
* 한 줄 되돌리기로 합치는 일은 346-4.
* ========================================================================== */
export type UndoPanel = "plan" | "profile";
let last: UndoPanel = "profile";
let planListening = false;
let bound = false;
function remember(event: Event): void {
const target = event.target as Element | null;
if (!target?.closest) return;
if (target.closest(".b05-routeedit")) last = "plan";
else if (target.closest(".b05-route-profile")) last = "profile";
}
/** 평면 이력이 듣기 시작 · 멈춤 — 보기가 보일 때 켜고 숨을 때 끈다. */
export function setPlanUndoListening(on: boolean): void {
planListening = on;
if (bound) return;
bound = true;
window.addEventListener("pointerdown", remember, true);
}
/** 지금 Ctrl+Z · Y 를 받을 판. */
export function undoOwner(): UndoPanel {
return planListening && last === "plan" ? "plan" : "profile";
}
+2 -11
View File
@@ -36,9 +36,8 @@
} }
/* 하단 종단 패널: 그래프 + 12행 도면 테이블을 담기 위해 화면 높이의 60%를 차지한다. /* 하단 종단 패널: 그래프 + 12행 도면 테이블을 담기 위해 화면 높이의 60%를 차지한다.
3D 뷰포트를 밀어내지 않고 그 위를 덮는 오버레이로 띄운다 — 패널을 여닫아도 3D 뷰 페이지 안에서는 위 칸(평면 · 횡단 카드) 아래 **흐름**에 선다(`design_page.css` · 345-3) —
크기가 변하지 않아 카메라 시점과 렌더 비용이 그대로 유지된다. 여기 absolute 는 페이지 밖 쓰임 꼴. 접기 핸들로 내리면 위 칸이 전체 영역을 갖는다. */
접기 핸들로 언제든 내릴 수 있어 3D 뷰를 전체 영역으로 볼 수 있다. */
.b05-route-profile { .b05-route-profile {
position: absolute; position: absolute;
z-index: 3; z-index: 3;
@@ -639,11 +638,3 @@ html[data-size="compact"] .b05-route__view-group {
min-height: 0; min-height: 0;
border-radius: var(--radius-md); border-radius: var(--radius-md);
} }
/* 가운데 위 [3D] 단추 자리 — 노선 편집 본문이 서기 전 임시 칸. */
.b05-route__plan-tools {
display: flex;
flex: none;
gap: var(--spacing-8);
padding: var(--spacing-8) var(--spacing-16);
}
+45 -26
View File
@@ -1,10 +1,13 @@
/* 계획노선 편집 모달 — 큰 모달 하나. 계산이 오래 걸리는 조작이라 화면을 통째로 덮는다 /* 계획노선 편집(평면) — 종단 보기 가운데 위 본문(옛 3D 자리 · 345-3). 덮개 · 전체 화면은 걷음 —
(2026-09-06 사용자 확정). 색은 전부 테마 토큰을 쓴다. 재계산 대기 막만 화면을 덮는다(노선 재탐색 · CLAUDE.md 5장). 색은 전부 테마 토큰을 쓴다. */
배경(덮기 · 겹 · 색)은 공용 `.ui-modal__backdrop` — 여기는 칸 사이 · 가장자리만. */
.b05-routeedit { .b05-routeedit {
gap: var(--spacing-12); position: relative;
padding: var(--spacing-12); display: flex;
flex: 1 1 auto;
gap: var(--spacing-8);
min-height: 0;
padding: var(--spacing-8);
} }
/* 메인 창은 **왼쪽**, 횡단 두 판은 오른쪽 세로 칸(2026-09-12 사용자 지시 ⑱). /* 메인 창은 **왼쪽**, 횡단 두 판은 오른쪽 세로 칸(2026-09-12 사용자 지시 ⑱).
@@ -18,18 +21,25 @@
height: 100%; height: 100%;
overflow: hidden; overflow: hidden;
border: 1px solid var(--color-border); border: 1px solid var(--color-border);
border-radius: var(--radius-xl); border-radius: var(--radius-md);
background: var(--color-surface-raised); background: var(--color-surface-raised);
box-shadow: var(--shadow-lg);
} }
/* 제목줄 꼴(가로 · 가운데 · 간격)은 공용 `.ui-modal__top` — 여기는 띠 테두리만. */ /* 머리 = 도구줄 — 왼쪽 이름 · 오른쪽 단추 묶음. */
.b05-routeedit__head { .b05-routeedit__head {
display: flex;
flex: none; flex: none;
padding: var(--spacing-12) var(--spacing-16); align-items: center;
gap: var(--spacing-12);
padding: var(--spacing-8) var(--spacing-12);
border-bottom: 1px solid var(--color-border); border-bottom: 1px solid var(--color-border);
} }
.b05-routeedit__head > h3 {
margin: 0;
font-size: var(--text-body);
}
/* 제목행 — 왼쪽에 이름, **오른쪽 끝에 단추 묶음**(2026-09-12 사용자 지시 ⑪). /* 제목행 — 왼쪽에 이름, **오른쪽 끝에 단추 묶음**(2026-09-12 사용자 지시 ⑪).
단추는 공용 규격(`.ui-btn`)을 그대로 쓴다 — 모달이 따로 만든 크기를 걷어냈다. */ 단추는 공용 규격(`.ui-btn`)을 그대로 쓴다 — 모달이 따로 만든 크기를 걷어냈다. */
.b05-routeedit__actions { .b05-routeedit__actions {
@@ -131,21 +141,31 @@
border-top: 2px solid var(--map-route, #f97316); border-top: 2px solid var(--map-route, #f97316);
} }
/* 오른쪽 세로 칸 — 위아래 반씩 나눠 **지금 횡단**과 **이전 횡단**이 앉는다. */ /* 오른쪽 세로 칸 — 위아래 반씩 나눠 **지금 횡단**과 **이전 횡단**이 앉는다. 접는 칸(345-3) —
.b05-routeedit__side { 좁은 화면은 접힌 채 시작(`_Chrome`) · [횡단] 단추로 펴고 접는다. */
.b05-routeedit__side-wrap {
display: flex; display: flex;
flex: none; flex: none;
flex-direction: column; flex-direction: column;
gap: var(--spacing-12); gap: var(--spacing-8);
width: 452px; min-height: 0;
height: 100%;
} }
@media (width < 1500px) { .b05-routeedit__side-toggle {
/* 자리가 모자라면 오른쪽 칸을 접는다 — 지도가 먼저다. */ align-self: flex-end;
.b05-routeedit__side { }
display: none;
} .b05-routeedit__side {
display: flex;
flex: 1 1 auto;
flex-direction: column;
gap: var(--spacing-12);
width: 452px;
min-height: 0;
}
.b05-routeedit__side-wrap.is-collapsed > .b05-routeedit__side {
display: none;
} }
.b05-routeedit__cross { .b05-routeedit__cross {
@@ -280,11 +300,11 @@
line-height: 1.5; line-height: 1.5;
} }
/* 재계산 중에는 화면 전체를 덮는다 — 결과를 기다릴 수밖에 없는 조작(CLAUDE.md 5장). */ /* 재계산 중에는 화면 전체를 덮는다 — 결과를 기다릴 수밖에 없는 조작(노선 재탐색 · CLAUDE.md 5장). */
.b05-routeedit__busy { .b05-routeedit__busy {
position: absolute; position: fixed;
inset: 0; inset: 0;
z-index: 2; z-index: var(--z-modal, 1000);
display: flex; display: flex;
align-items: center; align-items: center;
justify-content: center; justify-content: center;
@@ -298,10 +318,9 @@
예전에는 모달 맨 아랫줄이라 지금 고른 것이 지도 어디인지 눈으로 안 이어졌다. 예전에는 모달 맨 아랫줄이라 지금 고른 것이 지도 어디인지 눈으로 안 이어졌다.
자리는 `_Label.ts` 가 매 프레임 잡아 준다 — 여기서는 모양만 정한다. */ 자리는 `_Label.ts` 가 매 프레임 잡아 준다 — 여기서는 모양만 정한다. */
.b05-routeedit__label { .b05-routeedit__label {
/* `document.body` 에 붙여 **화면 기준**으로 띄운다 — 모달이 `overflow: hidden` 이라 /* 편집 칸 안 절대 위치(345-3) — 자리는 지도 칸 안으로 가둔다(`_Label`). */
안에 두면 가장자리에서 잘린다(2026-09-07 사용자 지시 1). */ position: absolute;
position: fixed; z-index: 3;
z-index: calc(var(--z-modal, 1000) + 1);
display: flex; display: flex;
flex-direction: column; flex-direction: column;
gap: 4px; gap: 4px;
+2 -5
View File
@@ -176,9 +176,6 @@ const panel = design.createDesignPanel(
parts: { parts: {
structures: { root: node("section"), listRoot: node() }, structures: { root: node("section"), listRoot: node() },
settings: { nodes: [text("a")] }, settings: { nodes: [text("a")] },
route: {
plannedRouteButton: text("plan"),
},
crossView: { row: text("cross-row") }, crossView: { row: text("cross-row") },
standardSlot: text("std"), standardSlot: text("std"),
}, },
@@ -189,7 +186,7 @@ const panel = design.createDesignPanel(
const byTitle = (title) => const byTitle = (title) =>
panel.root.querySelectorAll(".ui-collapsible").find((s) => titleOf(s) === title); panel.root.querySelectorAll(".ui-collapsible").find((s) => titleOf(s) === title);
out.profileFirst = folds(panel.root); out.profileFirst = folds(panel.root);
const route = byTitle("계획노선"); const route = byTitle("페이지 설정"); // 345-3 계획노선 칸 지움 — 접힌 채 서는 칸
press(panel.root, route); // 사용자가 폄(공용 attachCollapsible) press(panel.root, route); // 사용자가 폄(공용 attachCollapsible)
panel.setView("cross"); panel.setView("cross");
out.crossFirst = folds(panel.root); out.crossFirst = folds(panel.root);
@@ -197,7 +194,7 @@ press(panel.root, byTitle("B06_View_Title"));
panel.setView("profile"); panel.setView("profile");
panel.setView("cross"); panel.setView("cross");
out.afterRedraw = folds(panel.root); out.afterRedraw = folds(panel.root);
out.sameRouteNode = byTitle("계획노선") === route; out.sameRouteNode = byTitle("페이지 설정") === route;
// ── B07 도면 목록 ───────────────────────────────────────────────────────────── // ── B07 도면 목록 ─────────────────────────────────────────────────────────────
const b07 = loadTs(path.join(ROOT, "B07_DesignDetail", "B07_DesignDetail_UI_Panels.ts")); const b07 = loadTs(path.join(ROOT, "B07_DesignDetail", "B07_DesignDetail_UI_Panels.ts"));
@@ -0,0 +1,91 @@
/* 345-3 Ctrl+Z · Y 한 판만 헬퍼 — `_RouteEdit_History`(평면 이력 단추 · 단축키)와 `_RouteEdit_UndoOwner` 를
* 트랜스파일해 가짜 창(window)에서 돌림. 종단도 쪽 듣는 자리는 `_Profile_Panel_Tools` 와 같은 꼴
* (`undoOwner() === "plan"` 이면 안 받음)로 흉내. 결과는 JSON 한 벌(표준출력).
*/
const fs = require("fs");
const path = require("path");
const ts = require(path.join(__dirname, "..", "..", "config", "node_modules", "typescript"));
const ROOT = path.join(__dirname, "..", "..");
const listeners = {};
global.window = {
addEventListener: (type, fn) => (listeners[type] ??= new Set()).add(fn),
removeEventListener: (type, fn) => listeners[type]?.delete(fn),
};
const fire = (type, event) => [...(listeners[type] ?? [])].forEach((fn) => fn(event));
const cache = new Map();
function loadTs(file) {
const full = path.resolve(file);
if (cache.has(full)) return cache.get(full);
const js = ts.transpileModule(fs.readFileSync(full, "utf8"), {
compilerOptions: { module: ts.ModuleKind.CommonJS, target: ts.ScriptTarget.ES2020 },
}).outputText;
const box = { exports: {} };
cache.set(full, box.exports);
const localRequire = (request) =>
request.startsWith(".")
? loadTs(`${path.resolve(path.dirname(full), request)}.ts`)
: require(request);
new Function("exports", "module", "require", js)(box.exports, box, localRequire);
return box.exports;
}
const page = path.join(ROOT, "B05_Profile");
const { bindHistoryControls } = loadTs(path.join(page, "B05_Profile_UI_RouteEdit_History.ts"));
const { undoOwner } = loadTs(path.join(page, "B05_Profile_UI_RouteEdit_UndoOwner.ts"));
const button = () => ({ disabled: false, addEventListener: () => undefined });
const buttons = { '[data-act="undo"]': button(), '[data-act="redo"]': button() };
const overlay = { querySelector: (sel) => buttons[sel] };
const plan = [];
const history = {
undo: () => (plan.push("undo"), { tag: "u" }),
redo: () => (plan.push("redo"), { tag: "r" }),
canUndo: () => true,
canRedo: () => true,
};
const controls = bindHistoryControls({
overlay,
getHistory: () => history,
restore: () => undefined,
});
// 종단도 듣는 자리 흉내 — `_Profile_Panel_Tools` 의 Ctrl+Z 줄과 같은 거름.
const profile = [];
window.addEventListener("keydown", (event) => {
if (!(event.ctrlKey && event.key === "z")) return;
if (undoOwner() === "plan") return;
profile.push(event.shiftKey ? "redo" : "undo");
});
const key = (k, shift = false) =>
fire("keydown", { ctrlKey: true, key: k, code: "", shiftKey: shift, preventDefault() {} });
const press = (cls) =>
fire("pointerdown", { target: { closest: (sel) => (sel === `.${cls}` ? {} : null) } });
const snap = () => ({ plan: [...plan], profile: [...profile], owner: undoOwner() });
const out = {};
key("z"); // 듣기 전 — 종단도만
out.beforeListen = snap();
controls.listen(true);
key("z"); // 아직 아무 판도 안 만짐 — 종단도
out.untouched = snap();
press("b05-routeedit");
key("z");
key("y");
out.afterPlanPress = snap();
press("b05-route-profile");
key("z");
out.afterProfilePress = snap();
press("b05-routeedit");
controls.listen(false); // 보기 숨김 — 평면 이력은 안 들음
key("z");
out.hidden = snap();
controls.listen(true);
key("z", true); // 다시 보임 · 마지막에 만진 판 = 평면 · Shift+Z = 다시하기
out.shown = snap();
controls.dispose();
key("z");
out.disposed = snap();
console.log(JSON.stringify(out));
@@ -197,9 +197,6 @@ const built = panel.createDesignPanel(
parts: { parts: {
structures: sample(), structures: sample(),
settings: { nodes: [text("a"), text("b")] }, settings: { nodes: [text("a"), text("b")] },
route: {
plannedRouteButton: text("plan"),
},
crossView: { row: text("cross-row") }, crossView: { row: text("cross-row") },
standardSlot: text("std"), standardSlot: text("std"),
}, },
+4 -3
View File
@@ -42,15 +42,16 @@ def out():
def test_B05_B06_첫_그림_접힘(out): def test_B05_B06_첫_그림_접힘(out):
assert out["profileFirst"] == { assert out["profileFirst"] == {
"페이지 설정": True, "페이지 설정": True,
"계획노선": True,
} }
assert out["crossFirst"]["B06_View_Title"] is True assert out["crossFirst"]["B06_View_Title"] is True
assert out["crossFirst"]["B06_Std_Title"] is True # 옛 기본 접힘 그대로 assert out["crossFirst"]["B06_Std_Title"] is True # 옛 기본 접힘 그대로
def test_B05_B06_편_뒤_다시_그림_유지(out): def test_B05_B06_편_뒤_다시_그림_유지(out):
assert out["crossFirst"]["계획노선"] is False # 편 것 → 보기 바꿔도 그대로 assert (
assert out["afterRedraw"]["계획노선"] is False out["crossFirst"]["페이지 설정"] is False
) # 편 것 → 보기 바꿔도 그대로(345-3 계획노선 칸 지움)
assert out["afterRedraw"]["페이지 설정"] is False
assert out["afterRedraw"]["B06_View_Title"] is False assert out["afterRedraw"]["B06_View_Title"] is False
assert out["sameRouteNode"] is True assert out["sameRouteNode"] is True
+1 -1
View File
@@ -64,6 +64,6 @@ def test_배선_3D_상주_없음_죽은_길_지움():
for gone in ("beginMoveSelected", "deleteSelected", "updateSelected", "panel.setSelected"): for gone in ("beginMoveSelected", "deleteSelected", "updateSelected", "panel.setSelected"):
assert gone not in view assert gone not in view
assert "selected-point" not in _read("B05_Profile/B05_Profile_UI_Panel_Sections.ts") assert "selected-point" not in _read("B05_Profile/B05_Profile_UI_Panel_Sections.ts")
assert "selectedNodes" not in _read("B05_Profile/B05_Profile_UI_Panel_Route.ts") assert not (ROOT / "B05_Profile/B05_Profile_UI_Panel_Route.ts").exists() # 345-3 칸째 지움
assert "beginMoveSelected" not in _read("B05_Profile/B05_Profile_UI_Viewer.ts") assert "beginMoveSelected" not in _read("B05_Profile/B05_Profile_UI_Viewer.ts")
assert len(view.splitlines()) <= 700 assert len(view.splitlines()) <= 700
+93
View File
@@ -0,0 +1,93 @@
"""345-3 노선 편집 → 종단 보기 가운데 위 본문 — Ctrl+Z · Y 는 마지막에 만진 판 하나 · 종단도 판 흐름 배치 ·
왼쪽 「계획노선」 칸 · 편집 단추 지움 · [노선 반영] 뒤 새로고침 없이 다시 세움.
Ctrl+Z 몫은 Node 헬퍼 `helper_345_3_undo_owner.cjs` 가 진짜 `_RouteEdit_History` · `_UndoOwner` 로 돌림 ·
배선 · CSS 는 글로 봄(화면 확인은 브레인).
"""
from __future__ import annotations
import json
import re
import subprocess
from pathlib import Path
import pytest
HERE = Path(__file__).resolve().parent
ROOT = HERE.parents[1]
def _read(rel: str) -> str:
return (ROOT / rel).read_text(encoding="utf-8")
def _block(css: str, selector: str) -> str:
found = re.search(rf"^{re.escape(selector)} \{{([^}}]*)\}}", css, re.M)
assert found, selector
return found.group(1)
@pytest.fixture(scope="module")
def out() -> dict:
run = subprocess.run(
["node", str(HERE / "helper_345_3_undo_owner.cjs")],
capture_output=True,
text=True,
encoding="utf-8",
check=True,
)
return json.loads(run.stdout)
def test_Ctrl_Z_는_마지막에_만진_판_하나(out):
assert out["beforeListen"] == {"plan": [], "profile": ["undo"], "owner": "profile"}
assert out["untouched"]["plan"] == [] and out["untouched"]["profile"] == ["undo", "undo"]
# 평면을 누른 뒤 — 평면만(종단도 안 움직임)
assert out["afterPlanPress"]["plan"] == ["undo", "redo"]
assert out["afterPlanPress"]["profile"] == ["undo", "undo"]
# 종단 판을 누른 뒤 — 종단도만
assert out["afterProfilePress"]["plan"] == ["undo", "redo"]
assert out["afterProfilePress"]["profile"] == ["undo", "undo", "undo"]
# 보기 숨김(평면 안 들음) — 평면을 마지막에 만졌어도 종단도가 받음
assert out["hidden"]["owner"] == "profile"
assert out["hidden"]["profile"] == ["undo", "undo", "undo", "undo"]
# 다시 보임 — 평면 · Shift+Z = 다시하기
assert out["shown"]["plan"] == ["undo", "redo", "redo"]
assert out["shown"]["profile"] == out["hidden"]["profile"]
# 떠남 — 평면 단축키 뗌
assert out["disposed"]["plan"] == out["shown"]["plan"]
def test_종단도_Ctrl_Z_줄이_평면을_묻는다():
tools = _read("B05_Profile/B05_Profile_UI_Profile_Panel_Tools.ts")
at = tools.index('if (isCtrlKey(event, "z")) {')
assert 'if (undoOwner() === "plan") return;' in tools[at : at + 160]
history = _read("B05_Profile/B05_Profile_UI_RouteEdit_History.ts")
assert 'undoOwner() !== "plan"' in history
assert (
'window.addEventListener("keydown", onKey);\n\n return' not in history
) # 만들 때 안 붙음
def test_편집기는_본문_페이지_수명():
view = _read("A00_Common/design/design_view_profile.ts")
assert "mountRouteEditor(mainContent, host, activeProjectId, rebuildAfterReplan," in view
assert "tools: [view3d.modal.button]," in view
assert "openRouteEditModal" not in view and "location.reload()" not in view
assert len(view.splitlines()) <= 700
def test_종단도_판은_두_보기_모두_흐름():
css = _read("A00_Common/design/design_page.css")
rule = _block(css, ".design-page__main > .design-page__profile > .b05-route-profile")
assert "position: relative;" in rule and "flex: none;" in rule
assert '[data-view="cross"] > .design-page__profile > .b05-route-profile' not in css
def test_계획노선_칸_편집_단추_지움():
sections = _read("B05_Profile/B05_Profile_UI_Panel_Sections.ts")
assert '"planned-route"' not in sections and "plannedRouteButton" not in sections
panel = _read("B05_Profile/B05_Profile_UI_Panel.ts")
assert "onEditPlannedRoute" not in panel and "createRoutePart" not in panel
assert not (ROOT / "B05_Profile/B05_Profile_UI_Panel_Route.ts").exists()
@@ -1,4 +1,10 @@
"""계획노선 편집 겉틀 = 공용 모달 조각(PLAN 127-1) — 배경 · 제목줄은 공용 클래스, 닫기 규칙은 그대로.""" """계획노선 편집 겉틀 — 345-3 부터 모달이 아니라 종단 보기 가운데 위 본문(PLAN 127-1 의 공용 모달 조각은 걷음).
· 덮개(`ui-modal__backdrop`) · 전체 화면 · 닫기 단추 없음 · 부모를 받아 붙음(`document.body` 아님)
· [취소] → [반영 전으로](이력 처음으로) · [확인] → [노선 반영] · 머리 끝 `tools` 자리([3D])
· 오른쪽 횡단 미리보기 = 접는 칸 · 재계산 대기 막은 화면 전체(fixed) · R 라벨은 칸 안 절대 위치
· 바깥 누름 · Esc 로 안 닫힘(모달 아님)
"""
from __future__ import annotations from __future__ import annotations
@@ -15,34 +21,54 @@ def _block(css: str, selector: str) -> str:
return found.group(1) return found.group(1)
def test_겉틀은_공용_모달_조각(): def _read(name: str) -> str:
chrome = (PAGE / "B05_Profile_UI_RouteEdit_Chrome.ts").read_text(encoding="utf-8") return (PAGE / name).read_text(encoding="utf-8")
assert 'import "@ui/ui_template_modal.css";' in chrome
assert '"ui-modal__backdrop b05-routeedit"' in chrome
assert '<div class="ui-modal__top b05-routeedit__head">' in chrome def test_겉틀은_본문_칸():
assert "<h3>계획노선 편집</h3>" in chrome chrome = _read("B05_Profile_UI_RouteEdit_Chrome.ts")
assert "<strong>계획노선 편집</strong>" not in chrome assert "ui-modal" not in chrome
assert "document.body.append" not in chrome
assert "parent.append(overlay);" in chrome
assert 'overlay.className = "b05-routeedit";' in chrome
assert "b05-routeedit__close" not in chrome and 'data-slot="close"' not in chrome
assert '"tools"' in chrome and "replaceWith(...tools)" in chrome
assert ">반영 전으로</button>" in chrome and ">노선 반영</button>" in chrome
assert ">취소</button>" not in chrome and ">확인</button>" not in chrome
# 시험 · 배선이 집는 자리는 남김 # 시험 · 배선이 집는 자리는 남김
for hook in ( for hook in ('data-act="apply"', 'data-act="cancel"', "b05-routeedit__measure-close"):
'data-act="apply"',
'data-act="cancel"',
"b05-routeedit__close",
"b05-routeedit__measure-close",
):
assert hook in chrome assert hook in chrome
assert 'data-act="side-toggle"' in chrome and "is-collapsed" in chrome
def test_겹치는_CSS는_공용에_맡김(): def test_CSS_덮개_걷음_대기막은_전체():
css = (PAGE / "B05_Profile_UI_Style_RouteEdit.css").read_text(encoding="utf-8") css = _read("B05_Profile_UI_Style_RouteEdit.css")
overlay = _block(css, ".b05-routeedit") root = _block(css, ".b05-routeedit")
for prop in ("position:", "z-index:", "background:", "align-items:"): assert "position: relative;" in root and "flex: 1 1 auto;" in root
assert prop not in overlay assert "z-index" not in root and "background" not in root
head = _block(css, ".b05-routeedit__head") busy = _block(css, ".b05-routeedit__busy")
for prop in ("display:", "align-items:", "gap:"): assert "position: fixed;" in busy and "z-index: var(--z-modal, 1000);" in busy
assert prop not in head label = _block(css, ".b05-routeedit__label")
assert "position: absolute;" in label and "position: fixed" not in label
assert "display: none;" in _block(
css, ".b05-routeedit__side-wrap.is-collapsed > .b05-routeedit__side"
)
assert "@media (width < 1500px)" not in css # 접기는 단추로(처음만 폭으로)
def test_바깥_누름_Esc_로_안_닫힘(): def test_페이지_수명_닫기_없음():
main = (PAGE / "B05_Profile_UI_RouteEdit.ts").read_text(encoding="utf-8") main = _read("B05_Profile_UI_RouteEdit.ts")
assert "openModal" not in main assert "openModal" not in main and "Escape" not in main
assert "Escape" not in main assert "openRouteEditModal" not in main
assert "export async function mountRouteEditor(" in main
assert 'window.addEventListener("resize"' not in main # 칸 크기는 ResizeObserver
assert "new ResizeObserver(" in main
assert "host.onDispose(dispose);" in main
assert "historyControls.listen(host.visible());" in main
assert "history?.reset();" in main # [반영 전으로]
assert len(main.splitlines()) <= 700
label = _read("B05_Profile_UI_RouteEdit_Label.ts")
assert "document.body.append" not in label and "container.append(root);" in label
assert "container: overlay," in main
apply = _read("B05_Profile_UI_RouteEdit_Apply.ts")
assert "close" not in apply.split("export function bindRouteApply", 1)[1]
+16 -4
View File
@@ -98,13 +98,25 @@ def test_진행_단계_두_칸이_보기를_따라감():
def test_초기화_계획노선_편집은_다시_세움(): def test_초기화_계획노선_편집은_다시_세움():
"""[초기화] · 계획노선 편집 [확인] = 새 초기값 — 옛 캐시를 다 버리고 페이지를 통째로 다시 읽는다(PLAN 77).""" """[초기화] · [노선 반영](옛 계획노선 편집 [확인]) = 새 초기값 — 옛 캐시를 다 버리고 페이지를 통째로 다시 읽는다(PLAN 77)."""
reset = _read("A00_Common/design/design_reset.ts") reset = _read("A00_Common/design/design_reset.ts")
assert "clearDrafts(projectId);" in reset and "clearResults(projectId);" in reset assert "clearDrafts(projectId);" in reset and "clearResults(projectId);" in reset
assert "location.reload()" in reset assert "location.reload()" in reset
assert "openRouteEditModal(activeProjectId, () => location.reload()" in _read( view = _read("A00_Common/design/design_view_profile.ts")
"A00_Common/design/design_view_profile.ts" assert "location.reload()" not in view # [노선 반영]은 새로고침 대신 다시 세움(345-3)
) rebuild = view[view.index("const rebuildAfterReplan") :]
rebuild = rebuild[: rebuild.index("};")]
for step in (
"clearRouteLatestCache(activeProjectId);",
"invalidateSectionDetail(activeProjectId);",
"remountRoute(currentRoute());",
):
assert step in rebuild
assert rebuild.index("invalidateSectionDetail") < rebuild.index("remountRoute(")
assert "mountRouteEditor(mainContent, host, activeProjectId, rebuildAfterReplan," in view
apply = _read("B05_Profile/B05_Profile_UI_RouteEdit_Apply.ts")
assert apply.index("clearDrafts(projectId);") < apply.index("await params.onApplied();")
assert "clearResults(projectId);" in apply
def test_종단_보기_확정은_횡단_보기를_숨긴_채_세움(): def test_종단_보기_확정은_횡단_보기를_숨긴_채_세움():
+2 -4
View File
@@ -59,9 +59,8 @@ def results():
def test_보기를_오가도_좌측_패널_단추_구조물_배치는_같은_노드(results): def test_보기를_오가도_좌측_패널_단추_구조물_배치는_같은_노드(results):
assert results["sameNodes"] == {"cross": True, "back": True} assert results["sameNodes"] == {"cross": True, "back": True}
# 보기마다 새로 만들지 않는다 — 칸은 처음 보일 때 한 번만 서고 뒤로는 숨김 · 보임만. # 보기마다 새로 만들지 않는다 — 칸은 처음 보일 때 한 번만 서고 뒤로는 숨김 · 보임만.
assert ( # 아래 고정 영역 + 종단 칸 둘 + 횡단 칸 둘(345-2 선택 포인트 · 345-3 계획노선 칸 지움)
results["childCount"] == 1 + 3 + 2 assert results["childCount"] == 1 + 2 + 2
) # 아래 고정 영역 + 종단 칸 셋 + 횡단 칸 둘(345-2 선택 포인트 지움)
def test_구조물_배치는_두_보기_모두_맨_위_같은_DOM(results): def test_구조물_배치는_두_보기_모두_맨_위_같은_DOM(results):
@@ -81,7 +80,6 @@ def test_보기마다_제_칸만_보인다(results):
assert views["profile"]["shownTitles"] == [ assert views["profile"]["shownTitles"] == [
"구조물 배치", "구조물 배치",
"페이지 설정", "페이지 설정",
"계획노선",
] ]
assert views["cross"]["shownTitles"] == ["구조물 배치", "횡단 보기 설정", "표준 횡단면 설정"] assert views["cross"]["shownTitles"] == ["구조물 배치", "횡단 보기 설정", "표준 횡단면 설정"]
assert views["back"]["shownTitles"] == views["profile"]["shownTitles"] assert views["back"]["shownTitles"] == views["profile"]["shownTitles"]