Merge remote-tracking branch 'origin/sub_laptop_5' into main_laptop_1
This commit is contained in:
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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>;
|
||||
@@ -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();
|
||||
|
||||
@@ -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 () =>
|
||||
|
||||
@@ -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) };
|
||||
}
|
||||
|
||||
@@ -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";
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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"),
|
||||
},
|
||||
|
||||
@@ -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
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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]
|
||||
|
||||
@@ -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_종단_보기_확정은_횡단_보기를_숨긴_채_세움():
|
||||
|
||||
@@ -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"]
|
||||
|
||||
Reference in New Issue
Block a user