Merge remote-tracking branch 'origin/sub_laptop_5' into main_laptop_1

This commit is contained in:
2026-10-16 11:27:21 +09:00
24 changed files with 564 additions and 283 deletions
+3 -3
View File
@@ -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;
}
+25 -18
View File
@@ -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);
+2 -6
View File
@@ -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<void>;
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 {
-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
* 종단 보기 좌측 패널의 **칸 정의**(등록부 `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<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 { 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();
+51 -24
View File
@@ -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<DesignPageHost, "visible" | "onShow" | "onHide" | "onDispose">;
/** 편집기를 `parent` 에 세운다 — 자료를 읽고 나면 끝난다(떠날 때 정리는 `host.onDispose`). */
export async function mountRouteEditor(
parent: HTMLElement,
host: RouteEditHost,
projectId: string,
onApplied: () => void | Promise<void>,
options: RouteEditOptions = {},
): Promise<void> {
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<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] =>
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();
@@ -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<number | null> };
/** 창을 연 뒤 노선이 바뀌었나 — 안 바뀌었으면 다시 계산하지 않는다. */
/** 마지막 반영 뒤 노선이 바뀌었나 — 안 바뀌었으면 다시 계산하지 않는다. */
changed: () => boolean;
/** 설계 지형으로 보낼 분석 지형(PLAN 133-2) — 고르기가 안 보이면 null(지금 설계 지형 그대로). */
terrain: () => string | null;
@@ -36,12 +37,11 @@ export interface RouteApplyParams {
};
/** 종단 띠 — 응답을 기다려 띠에 보인 계획선(균형 종단 · 지반고)을 그대로 적용한다(PLAN 75-1). */
profileShown: () => Promise<boolean>;
/** 성공하면 모달을 닫고 화면을 다시 읽는다. */
close: () => void;
/** 성공하면 화면을 다시 세운다(대기 막은 다시 세울 때까지 남음). */
onApplied: () => void | Promise<void>;
}
/** [확인]을 붙인다. 리스너는 모달과 수명이 같다. */
/** [노선 반영]을 붙인다. 리스너는 편집기와 수명이 같다. */
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 () =>
+34 -20
View File
@@ -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 = `
<div class="b05-routeedit__box ui-popup" role="dialog" aria-label="계획노선 편집">
<div class="ui-modal__top b05-routeedit__head">
<h3>계획노선 편집</h3>
<div class="b05-routeedit__box" aria-label="계획노선 편집">
<div class="b05-routeedit__head">
<h3>계획노선</h3>
<span class="b05-routeedit__rec-now" data-rec-now></span>
<span class="b05-routeedit__actions">
<span data-slot="tools"></span>
<button type="button" class="ui-btn ui-btn--ghost" data-act="recommend" disabled
title="예상노선 추적 · 영선 · 토공 최소 · 기울기 최소 · 비중 계산 중 하나로 노선을 추천받아 미리 봅니다">노선 추천</button>
<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"
title="다시하기 (Ctrl+Y)" disabled>↷ 다시하기</button>
<button type="button" class="ui-btn ui-btn--ghost" data-act="reset" disabled
title="전처리 때 만든 초기값 노선(예상노선 추적)으로 되돌립니다 — 다시 계산은 [확인]에서">초기화</button>
<button type="button" class="ui-btn ui-btn--ghost" data-act="cancel">취소</button>
title="전처리 때 만든 초기값 노선(예상노선 추적)으로 되돌립니다 — 다시 계산은 [노선 반영]에서">초기화</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"
title="이 노선으로 배수유역부터 다시 계산합니다 — 결과가 새 초기값이 되고 종횡단 · 구조물 · 관 옵션 편집은 새 노선 기본값으로 바뀝니다">확인</button>
title="이 노선으로 배수유역부터 다시 계산합니다 — 결과가 새 초기값이 되고 종횡단 · 구조물 · 관 옵션 편집은 새 노선 기본값으로 바뀝니다">노선 반영</button>
</span>
<span data-slot="close"></span>
</div>
<div class="b05-routeedit__canvas-wrap">
<canvas class="b05-routeedit__canvas"></canvas>
@@ -117,13 +121,23 @@ export function createRouteEditChrome(): RouteEditChrome {
</div>
<div class="b05-routeedit__busy" hidden><span></span></div>
</div>
<div class="b05-routeedit__side"></div>`;
document.body.append(overlay);
<aside class="b05-routeedit__side-wrap">
<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");
closeButton.setAttribute("aria-label", "닫기");
overlay.querySelector('[data-slot="close"]')!.replaceWith(closeButton);
// 횡단 미리보기 = 접는 칸 — 좁은 화면은 접힌 채 시작(지도가 먼저다).
const sideWrap = overlay.querySelector<HTMLElement>(".b05-routeedit__side-wrap")!;
const sideToggle = overlay.querySelector<HTMLButtonElement>('[data-act="side-toggle"]')!;
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" });
measureCloseButton.classList.add("b05-routeedit__measure-close");
@@ -42,6 +42,8 @@ export interface CurveBarState {
export interface CurveBarParams {
canvas: HTMLCanvasElement;
/** 패널이 앉을 칸(편집기 뿌리 · 345-3). */
container: HTMLElement;
state: () => CurveBarState;
/** 그리기 좌표로 옮긴다. 돌린 지도에서는 **돌린 뒤 자리**를 줘야 패널이 노드 옆에 붙는다. */
toScreen: (vertex: Vertex) => [number, number];
@@ -58,62 +60,67 @@ export interface CurveBar {
}
export function createCurveBar(params: CurveBarParams): CurveBar {
const label = createCurveLabel({
onClose: () => params.onUnselect(),
onRadius: (value) => {
const { picked, curveRadius } = params.state();
if (picked < 0) return;
curveRadius[picked] = value;
// 반지름만 바꾼 것이라 노드 자리는 그대로지만, 그려진 선은 낡았다.
params.applyEdit(value === null ? "반지름을 자동으로 되돌렸습니다." : "반지름을 바꿨습니다.");
const label = createCurveLabel(
{
onClose: () => params.onUnselect(),
onRadius: (value) => {
const { picked, curveRadius } = params.state();
if (picked < 0) return;
curveRadius[picked] = value;
// 반지름만 바꾼 것이라 노드 자리는 그대로지만, 그려진 선은 낡았다.
params.applyEdit(
value === null ? "반지름을 자동으로 되돌렸습니다." : "반지름을 바꿨습니다.",
);
},
onArcLength: (value) => {
const { picked, nodeInfo, curveArc, curveRadius } = params.state();
if (picked < 0) return;
// 곡선 길이 L 과 반지름 R 은 L = R·Δ 로 묶여 있다(Δ = 교각, 앞뒤 직선이 정함).
// 그래서 길이를 받으면 반지름으로 바꿔 **한 값만** 들고 간다 — 두 벌로 두면 어긋난다.
const deflection = deflectionRad(nodeInfo[picked]?.inner_angle_deg);
curveArc[picked] = value;
curveRadius[picked] = value !== null && deflection > 1e-9 ? value / deflection : null;
params.applyEdit(
value === null ? "반지름을 자동으로 되돌렸습니다." : "곡선 길이를 바꿨습니다.",
);
},
onLock: (lock) => {
const { picked, nodeInfo, curveArc, curveRadius, curveLock } = params.state();
if (picked < 0) return;
curveLock[picked] = lock;
const deflection = deflectionRad(nodeInfo[picked]?.inner_angle_deg);
const shown = curveRadius[picked] ?? nodeInfo[picked]?.radius_m ?? null;
// 길이를 붙들려면 지금 길이를 적어 둬야 한다 — 뒤에 교각이 바뀌면 이 값으로 R 을 다시 잡는다.
if (lock === "arc") {
curveArc[picked] = shown !== null && deflection > 1e-9 ? shown * deflection : null;
}
// R 을 붙들 때 칸이 비어 있으면 지금 그려진 R 을 적어 둔다(자동 상태를 그대로 못 박음).
if (lock === "radius" && curveRadius[picked] === null) curveRadius[picked] = shown;
params.applyEdit(
lock === "radius"
? "반지름을 고정했습니다."
: lock === "arc"
? "곡선 길이를 고정했습니다."
: "고정을 풀었습니다.",
);
},
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) => {
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 ? "곡선을 넣었습니다." : "곡선을 지웠습니다.");
},
});
params.container,
);
/** 고른 자리에 맞춰 라벨을 옮겨 그린다. 끝점은 곡선이 없어 **고정 단추만** 낸다(계획서 22-5). */
function sync(): void {
@@ -169,7 +176,7 @@ export function createCurveBar(params: CurveBarParams): CurveBar {
const [screenX, screenY] = params.toScreen(planned[picked]);
label.show({
seat: picked,
// 패널은 `position: fixed` 라 **화면 좌표**로 넘긴다.
// 패널 자리는 **화면 좌표**로 넘긴다(칸 안 좌표로는 `_Label` 이 바꿔 적음).
at: [screenX + rect.left, screenY + rect.top],
// 넘어가도 되는 테두리 = **지도 칸**(하단 정보행 위까지). 밖으로 나가면 지금 무엇을
// 고치는지 모달 안에서 안 보인다(2026-09-12 사용자 지적 ⑨).
@@ -11,6 +11,7 @@
* ========================================================================== */
import { keyLetter } from "../A00_Common/key_shortcut";
import { setPlanUndoListening, undoOwner } from "./B05_Profile_UI_RouteEdit_UndoOwner";
export type Vertex = [number, number];
@@ -126,10 +127,12 @@ export interface HistoryControlsParams {
/** [되돌리기]·[다시하기] 단추와 단축키(Ctrl+Z / Ctrl+Y / Ctrl+Shift+Z)를 붙인다.
*
* 돌려주는 `sync` 를 편집이 끝날 때마다 부르면 단추 켜짐이 맞춰진다. `dispose` 는 창을
* 닫을 때 부른다 — 단축키를 창(window)에 달았기 때문에 안 떼면 닫힌 뒤에도 살아 있다. */
* 돌려주는 `sync` 를 편집이 끝날 때마다 부르면 단추 켜짐이 맞춰진다. 단축키는 `listen(true)` 부터
* 듣는다(보기가 보일 때 · 345-3) — 창(window)에 달았기 때문에 숨을 때 · 떠날 때 `listen(false)`.
* 종단도도 같은 키를 들어 **마지막에 만진 판**만 받는다(`_UndoOwner`). */
export function bindHistoryControls(params: HistoryControlsParams): {
sync: () => void;
listen: (on: boolean) => void;
dispose: () => void;
} {
const { overlay, getHistory, restore } = params;
@@ -155,14 +158,18 @@ export function bindHistoryControls(params: HistoryControlsParams): {
redoBtn.addEventListener("click", () => step("redo"));
const onKey = (event: KeyboardEvent): void => {
if (!(event.ctrlKey || event.metaKey)) return;
if (!(event.ctrlKey || event.metaKey) || undoOwner() !== "plan") return;
const key = keyLetter(event);
if (key === "z" && !event.shiftKey) step("undo");
else if (key === "y" || (key === "z" && event.shiftKey)) step("redo");
else return;
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` 설명 참고).
*
* **자리**(2026-09-07 사용자 지시)
* · 몸통은 `document.body` 에 `position: fixed` 로 띄운다 — 모달이 `overflow: hidden` 이라
* 안에 두면 가장자리에서 **잘린다**.
* · 몸통은 편집 칸(`container`) 안에 `position: absolute` 로 띄운다(345-3 · 본문 평면) — 셈은
* 화면(viewport) 좌표로 하고 적을 때만 칸 왼쪽 위만큼 뺀다.
* · 다만 **지도 칸 밖으로는 안 나간다**(2026-09-12 사용자 지적 ⑨) — 상자 밖이나 하단
* 정보행 위로 넘어가면 지금 무엇을 고치는지 모달 안에서 안 보인다. 「잘리지 않게」와
* 「상자 밖으로 나가게」는 다른 문제여서 자리 계산에서만 가둔다.
@@ -97,7 +97,7 @@ export interface CurveLabelHandlers {
export interface CurveLabel {
show: (state: CurveLabelState) => void;
hide: () => void;
/** 창을 닫을 때 — 몸통이 `document.body` 에 붙어 있어 스스로 안 사라진다. */
/** 편집기를 버릴 때 — 몸통을 칸에서 뗀다. */
destroy: () => void;
}
@@ -121,8 +121,11 @@ function unitLabel(text: string): HTMLSpanElement {
return span;
}
/** 패널을 만든다. 몸통은 `document.body` 에 붙어 모달 밖으로도 넘어간다. */
export function createCurveLabel(handlers: CurveLabelHandlers): CurveLabel {
/** 패널을 만든다. 몸통은 `container`(편집 칸 · position 이 있는 칸) 안에 붙는다. */
export function createCurveLabel(
handlers: CurveLabelHandlers,
container: HTMLElement = document.body,
): CurveLabel {
const root = document.createElement("div");
root.className = "b05-routeedit__label";
root.hidden = true;
@@ -138,7 +141,13 @@ export function createCurveLabel(handlers: CurveLabelHandlers): CurveLabel {
<span data-slot="lock-apex"></span>
</div>
<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 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;
head.addEventListener("pointerdown", (event) => {
if ((event.target as HTMLElement).closest("button")) return; // 단추는 단추대로.
dragFrom = {
x: event.clientX,
y: event.clientY,
left: root.offsetLeft,
top: root.offsetTop,
};
const box = root.getBoundingClientRect();
dragFrom = { x: event.clientX, y: event.clientY, left: box.left, top: box.top };
head.setPointerCapture(event.pointerId);
event.preventDefault();
});
@@ -248,8 +253,7 @@ export function createCurveLabel(handlers: CurveLabelHandlers): CurveLabel {
root.offsetHeight,
limit,
);
root.style.left = `${Math.round(left)}px`;
root.style.top = `${Math.round(top)}px`;
moveTo(left, top);
// 꺾임점 기준으로 기억한다 — 지도를 옮기거나 확대해도 같은 자리에 따라온다.
manual = [left - anchor[0], top - anchor[1]];
});
@@ -278,8 +282,7 @@ export function createCurveLabel(handlers: CurveLabelHandlers): CurveLabel {
height,
state.bounds,
);
root.style.left = `${Math.round(left)}px`;
root.style.top = `${Math.round(top)}px`;
moveTo(left, top);
}
/** 테두리 안으로 민다. 패널이 테두리보다 크면 왼쪽·위를 맞춰 **머리가 먼저 보이게** 한다. */
@@ -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%를 차지한다.
3D 뷰포트를 밀어내지 않고 그 위를 덮는 오버레이로 띄운다 — 패널을 여닫아도 3D 뷰
크기가 변하지 않아 카메라 시점과 렌더 비용이 그대로 유지된다.
접기 핸들로 언제든 내릴 수 있어 3D 뷰를 전체 영역으로 볼 수 있다. */
페이지 안에서는 위 칸(평면 · 횡단 카드) 아래 **흐름**에 선다(`design_page.css` · 345-3) —
여기 absolute 는 페이지 밖 쓰임 꼴. 접기 핸들로 내리면 위 칸이 전체 영역을 갖는다. */
.b05-route-profile {
position: absolute;
z-index: 3;
@@ -639,11 +638,3 @@ html[data-size="compact"] .b05-route__view-group {
min-height: 0;
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 @@
/* 계획노선 편집 모달 — 큰 모달 하나. 계산이 오래 걸리는 조작이라 화면을 통째로 덮는다
(2026-09-06 사용자 확정). 색은 전부 테마 토큰을 쓴다.
배경(덮기 · 겹 · 색)은 공용 `.ui-modal__backdrop` — 여기는 칸 사이 · 가장자리만. */
/* 계획노선 편집(평면) — 종단 보기 가운데 위 본문(옛 3D 자리 · 345-3). 덮개 · 전체 화면은 걷음 —
재계산 대기 막만 화면을 덮는다(노선 재탐색 · CLAUDE.md 5장). 색은 전부 테마 토큰을 쓴다. */
.b05-routeedit {
gap: var(--spacing-12);
padding: var(--spacing-12);
position: relative;
display: flex;
flex: 1 1 auto;
gap: var(--spacing-8);
min-height: 0;
padding: var(--spacing-8);
}
/* 메인 창은 **왼쪽**, 횡단 두 판은 오른쪽 세로 칸(2026-09-12 사용자 지시 ⑱).
@@ -18,18 +21,25 @@
height: 100%;
overflow: hidden;
border: 1px solid var(--color-border);
border-radius: var(--radius-xl);
border-radius: var(--radius-md);
background: var(--color-surface-raised);
box-shadow: var(--shadow-lg);
}
/* 제목줄 꼴(가로 · 가운데 · 간격)은 공용 `.ui-modal__top` — 여기는 띠 테두리만. */
/* 머리 = 도구줄 — 왼쪽 이름 · 오른쪽 단추 묶음. */
.b05-routeedit__head {
display: flex;
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);
}
.b05-routeedit__head > h3 {
margin: 0;
font-size: var(--text-body);
}
/* 제목행 — 왼쪽에 이름, **오른쪽 끝에 단추 묶음**(2026-09-12 사용자 지시 ⑪).
단추는 공용 규격(`.ui-btn`)을 그대로 쓴다 — 모달이 따로 만든 크기를 걷어냈다. */
.b05-routeedit__actions {
@@ -131,21 +141,31 @@
border-top: 2px solid var(--map-route, #f97316);
}
/* 오른쪽 세로 칸 — 위아래 반씩 나눠 **지금 횡단**과 **이전 횡단**이 앉는다. */
.b05-routeedit__side {
/* 오른쪽 세로 칸 — 위아래 반씩 나눠 **지금 횡단**과 **이전 횡단**이 앉는다. 접는 칸(345-3) —
좁은 화면은 접힌 채 시작(`_Chrome`) · [횡단] 단추로 펴고 접는다. */
.b05-routeedit__side-wrap {
display: flex;
flex: none;
flex-direction: column;
gap: var(--spacing-12);
width: 452px;
height: 100%;
gap: var(--spacing-8);
min-height: 0;
}
@media (width < 1500px) {
/* 자리가 모자라면 오른쪽 칸을 접는다 — 지도가 먼저다. */
.b05-routeedit__side {
display: none;
}
.b05-routeedit__side-toggle {
align-self: flex-end;
}
.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 {
@@ -280,11 +300,11 @@
line-height: 1.5;
}
/* 재계산 중에는 화면 전체를 덮는다 — 결과를 기다릴 수밖에 없는 조작(CLAUDE.md 5장). */
/* 재계산 중에는 화면 전체를 덮는다 — 결과를 기다릴 수밖에 없는 조작(노선 재탐색 · CLAUDE.md 5장). */
.b05-routeedit__busy {
position: absolute;
position: fixed;
inset: 0;
z-index: 2;
z-index: var(--z-modal, 1000);
display: flex;
align-items: center;
justify-content: center;
@@ -298,10 +318,9 @@
예전에는 모달 맨 아랫줄이라 지금 고른 것이 지도 어디인지 눈으로 안 이어졌다.
자리는 `_Label.ts` 가 매 프레임 잡아 준다 — 여기서는 모양만 정한다. */
.b05-routeedit__label {
/* `document.body` 에 붙여 **화면 기준**으로 띄운다 — 모달이 `overflow: hidden` 이라
안에 두면 가장자리에서 잘린다(2026-09-07 사용자 지시 1). */
position: fixed;
z-index: calc(var(--z-modal, 1000) + 1);
/* 편집 칸 안 절대 위치(345-3) — 자리는 지도 칸 안으로 가둔다(`_Label`). */
position: absolute;
z-index: 3;
display: flex;
flex-direction: column;
gap: 4px;
+2 -5
View File
@@ -176,9 +176,6 @@ const panel = design.createDesignPanel(
parts: {
structures: { root: node("section"), listRoot: node() },
settings: { nodes: [text("a")] },
route: {
plannedRouteButton: text("plan"),
},
crossView: { row: text("cross-row") },
standardSlot: text("std"),
},
@@ -189,7 +186,7 @@ const panel = design.createDesignPanel(
const byTitle = (title) =>
panel.root.querySelectorAll(".ui-collapsible").find((s) => titleOf(s) === title);
out.profileFirst = folds(panel.root);
const route = byTitle("계획노선");
const route = byTitle("페이지 설정"); // 345-3 계획노선 칸 지움 — 접힌 채 서는 칸
press(panel.root, route); // 사용자가 폄(공용 attachCollapsible)
panel.setView("cross");
out.crossFirst = folds(panel.root);
@@ -197,7 +194,7 @@ press(panel.root, byTitle("B06_View_Title"));
panel.setView("profile");
panel.setView("cross");
out.afterRedraw = folds(panel.root);
out.sameRouteNode = byTitle("계획노선") === route;
out.sameRouteNode = byTitle("페이지 설정") === route;
// ── B07 도면 목록 ─────────────────────────────────────────────────────────────
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: {
structures: sample(),
settings: { nodes: [text("a"), text("b")] },
route: {
plannedRouteButton: text("plan"),
},
crossView: { row: text("cross-row") },
standardSlot: text("std"),
},
+4 -3
View File
@@ -42,15 +42,16 @@ def out():
def test_B05_B06_첫_그림_접힘(out):
assert out["profileFirst"] == {
"페이지 설정": True,
"계획노선": True,
}
assert out["crossFirst"]["B06_View_Title"] is True
assert out["crossFirst"]["B06_Std_Title"] is True # 옛 기본 접힘 그대로
def test_B05_B06_편_뒤_다시_그림_유지(out):
assert out["crossFirst"]["계획노선"] is False # 편 것 → 보기 바꿔도 그대로
assert out["afterRedraw"]["계획노선"] is False
assert (
out["crossFirst"]["페이지 설정"] is False
) # 편 것 → 보기 바꿔도 그대로(345-3 계획노선 칸 지움)
assert out["afterRedraw"]["페이지 설정"] is False
assert out["afterRedraw"]["B06_View_Title"] is False
assert out["sameRouteNode"] is True
+1 -1
View File
@@ -64,6 +64,6 @@ def test_배선_3D_상주_없음_죽은_길_지움():
for gone in ("beginMoveSelected", "deleteSelected", "updateSelected", "panel.setSelected"):
assert gone not in view
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 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
@@ -15,34 +21,54 @@ def _block(css: str, selector: str) -> str:
return found.group(1)
def test_겉틀은_공용_모달_조각():
chrome = (PAGE / "B05_Profile_UI_RouteEdit_Chrome.ts").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
assert "<h3>계획노선 편집</h3>" in chrome
assert "<strong>계획노선 편집</strong>" not in chrome
def _read(name: str) -> str:
return (PAGE / name).read_text(encoding="utf-8")
def test_겉틀은_본문_칸():
chrome = _read("B05_Profile_UI_RouteEdit_Chrome.ts")
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 (
'data-act="apply"',
'data-act="cancel"',
"b05-routeedit__close",
"b05-routeedit__measure-close",
):
for hook in ('data-act="apply"', 'data-act="cancel"', "b05-routeedit__measure-close"):
assert hook in chrome
assert 'data-act="side-toggle"' in chrome and "is-collapsed" in chrome
def test_겹치는_CSS는_공용에_맡김():
css = (PAGE / "B05_Profile_UI_Style_RouteEdit.css").read_text(encoding="utf-8")
overlay = _block(css, ".b05-routeedit")
for prop in ("position:", "z-index:", "background:", "align-items:"):
assert prop not in overlay
head = _block(css, ".b05-routeedit__head")
for prop in ("display:", "align-items:", "gap:"):
assert prop not in head
def test_CSS_덮개_걷음_대기막은_전체():
css = _read("B05_Profile_UI_Style_RouteEdit.css")
root = _block(css, ".b05-routeedit")
assert "position: relative;" in root and "flex: 1 1 auto;" in root
assert "z-index" not in root and "background" not in root
busy = _block(css, ".b05-routeedit__busy")
assert "position: fixed;" in busy and "z-index: var(--z-modal, 1000);" in busy
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_로_안_닫힘():
main = (PAGE / "B05_Profile_UI_RouteEdit.ts").read_text(encoding="utf-8")
assert "openModal" not in main
assert "Escape" not in main
def test_페이지_수명_닫기_없음():
main = _read("B05_Profile_UI_RouteEdit.ts")
assert "openModal" not in main and "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_초기화_계획노선_편집은_다시_세움():
"""[초기화] · 계획노선 편집 [확인] = 새 초기값 — 옛 캐시를 다 버리고 페이지를 통째로 다시 읽는다(PLAN 77)."""
"""[초기화] · [노선 반영](옛 계획노선 편집 [확인]) = 새 초기값 — 옛 캐시를 다 버리고 페이지를 통째로 다시 읽는다(PLAN 77)."""
reset = _read("A00_Common/design/design_reset.ts")
assert "clearDrafts(projectId);" in reset and "clearResults(projectId);" in reset
assert "location.reload()" in reset
assert "openRouteEditModal(activeProjectId, () => location.reload()" in _read(
"A00_Common/design/design_view_profile.ts"
)
view = _read("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_종단_보기_확정은_횡단_보기를_숨긴_채_세움():
+2 -4
View File
@@ -59,9 +59,8 @@ def results():
def test_보기를_오가도_좌측_패널_단추_구조물_배치는_같은_노드(results):
assert results["sameNodes"] == {"cross": True, "back": True}
# 보기마다 새로 만들지 않는다 — 칸은 처음 보일 때 한 번만 서고 뒤로는 숨김 · 보임만.
assert (
results["childCount"] == 1 + 3 + 2
) # 아래 고정 영역 + 종단 칸 셋 + 횡단 칸 둘(345-2 선택 포인트 지움)
# 아래 고정 영역 + 종단 칸 둘 + 횡단 칸 둘(345-2 선택 포인트 · 345-3 계획노선 칸 지움)
assert results["childCount"] == 1 + 2 + 2
def test_구조물_배치는_두_보기_모두_맨_위_같은_DOM(results):
@@ -81,7 +80,6 @@ def test_보기마다_제_칸만_보인다(results):
assert views["profile"]["shownTitles"] == [
"구조물 배치",
"페이지 설정",
"계획노선",
]
assert views["cross"]["shownTitles"] == ["구조물 배치", "횡단 보기 설정", "표준 횡단면 설정"]
assert views["back"]["shownTitles"] == views["profile"]["shownTitles"]