diff --git a/A00_Common/design/design_view_profile.ts b/A00_Common/design/design_view_profile.ts index 319930f89..75eb2d58b 100644 --- a/A00_Common/design/design_view_profile.ts +++ b/A00_Common/design/design_view_profile.ts @@ -487,6 +487,7 @@ export async function mountProfileView( contourIntervalM: latestResponse.surface_params.contour_interval_m, smooth: latestResponse.surface_params.smooth, tools: [view3d.modal.button], + recommend: panel.recommend, }); // ③ 종단면·횡단 자료 — 하단 패널을 3D보다 먼저 채운다. diff --git a/B05_Profile/B05_Profile_UI_Panel.ts b/B05_Profile/B05_Profile_UI_Panel.ts index d4e7cb6fd..7ee996cbf 100644 --- a/B05_Profile/B05_Profile_UI_Panel.ts +++ b/B05_Profile/B05_Profile_UI_Panel.ts @@ -23,6 +23,7 @@ import { parseOptional, type RoutePanelValues, } from "./B05_Profile_UI_Panel_Fields"; +import { createRecommendPart } from "./B05_Profile_UI_Panel_Recommend"; import { createSettingsPart } from "./B05_Profile_UI_Panel_Settings"; import { createViewControls } from "./B05_Profile_UI_Panel_View"; @@ -71,6 +72,7 @@ interface PanelCallbacks { export function createRoutePanel(callbacks: PanelCallbacks, shared: DesignPageShared) { const { viewControls, setCorridorPending } = createViewControls(callbacks); const settings = createSettingsPart(callbacks); + const recommend = createRecommendPart(); // 노선 추천 칸 — 동작은 노선 편집기가 건다(345-4) const { contourInterval, gradeClass, @@ -119,11 +121,13 @@ export function createRoutePanel(callbacks: PanelCallbacks, shared: DesignPageSh // 좌측 패널 = 한 벌 등록부(`design_shared`) — 종단 보기 칸 재료만 넣는다. 아래 단추 넷 · 저장 · // [확정]은 한 벌이 두 보기 몫을 모아 낸다. - Object.assign(shared.parts, { settings }); + Object.assign(shared.parts, { settings, recommend }); sharedPanel(shared); return { viewControls, + /** 「노선 추천」 칸 요소 — 노선 편집기(`_RouteEdit_Initial`)에 넘긴다. */ + recommend, /** 「구조물 배치」 섹션 API(타입 주입·목록 교체·계곡 시설 병합·선택·추가/이동/삭제). */ structures: structures as StructuresSection, /** 이어 공사 시작 기준(측점번호·누가거리 오프셋) 현재값. */ diff --git a/B05_Profile/B05_Profile_UI_Panel_Recommend.ts b/B05_Profile/B05_Profile_UI_Panel_Recommend.ts new file mode 100644 index 000000000..3fa0ed2ae --- /dev/null +++ b/B05_Profile/B05_Profile_UI_Panel_Recommend.ts @@ -0,0 +1,29 @@ +/* ============================================================================= + * B05_Profile_UI_Panel_Recommend.ts + * 좌측 「노선 추천」 칸 재료(345-4 · 지운 「계획노선」 자리) — [노선 추천] 단추 · 「현재 추천: ○○」 · + * [초기화]. 노선 편집 머리에 있던 것을 옮겼다. 동작(추천 창 · 초기값 싣기)은 편집기의 + * `_RouteEdit_Initial` 이 이 요소를 받아 건다 — 여기는 생김새만. + * ========================================================================== */ + +export interface RecommendPart { + recommendButton: HTMLButtonElement; + resetButton: HTMLButtonElement; + nowLine: HTMLElement; + /** 칸 몸 — 화면 순서대로. */ + nodes: HTMLElement[]; +} + +export function createRecommendPart(): RecommendPart { + const nowLine = document.createElement("span"); + nowLine.className = "b05-routeedit__rec-now"; + const actions = document.createElement("div"); + actions.className = "b05-route__rec-actions"; + actions.innerHTML = ` + 노선 추천 + 초기화`; + const recommendButton = actions.querySelector('[data-act="recommend"]')!; + const resetButton = actions.querySelector('[data-act="reset"]')!; + return { recommendButton, resetButton, nowLine, nodes: [nowLine, actions] }; +} diff --git a/B05_Profile/B05_Profile_UI_Panel_Sections.ts b/B05_Profile/B05_Profile_UI_Panel_Sections.ts index 67c28ff32..4085c18fe 100644 --- a/B05_Profile/B05_Profile_UI_Panel_Sections.ts +++ b/B05_Profile/B05_Profile_UI_Panel_Sections.ts @@ -1,16 +1,18 @@ /* ============================================================================= * B05_Profile_UI_Panel_Sections.ts - * 종단 보기 좌측 패널의 **칸 정의**(등록부 `registerPanelSection`) — 페이지 설정. + * 종단 보기 좌측 패널의 **칸 정의**(등록부 `registerPanelSection`) — 페이지 설정 · 노선 추천. * 「구조물 배치」는 두 보기 공용이라 `design_sections.ts` 에 있다. * - * 칸 차례(2026-08-18 사용자 확정): 구조물 배치 > 페이지 설정 > 아래 고정 영역. + * 칸 차례(2026-08-18 사용자 확정): 구조물 배치 > 페이지 설정 > 노선 추천 > 아래 고정 영역. * 「선택 포인트 상세 설정」(3D 노선 점 옮김 · 지움 · 저장 안 되는 길)은 지움(345-2) · - * 「계획노선」 칸 · [계획노선 편집]은 지움(345-3 — 노선 편집은 가운데 위 본문). + * 「계획노선」 칸 · [계획노선 편집]은 지움(345-3 — 노선 편집은 가운데 위 본문) · 그 자리에 + * 「노선 추천」(345-4 · 편집 머리에서 옮김 · 재료 `_Panel_Recommend`). * 「경로 계산 설정」은 `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 { RecommendPart } from "./B05_Profile_UI_Panel_Recommend"; import type { SettingsPart } from "./B05_Profile_UI_Panel_Settings"; registerPanelSection({ @@ -24,3 +26,15 @@ registerPanelSection({ return frame.root; }, }); + +registerPanelSection({ + id: "route-recommend", + title: "노선 추천", + views: ["profile"], + order: 30, + build: (ctx, view) => { + const frame = createSectionFrame(view, "노선 추천"); + frame.body.append(...part(ctx, "recommend").nodes); + return frame.root; + }, +}); diff --git a/B05_Profile/B05_Profile_UI_RouteEdit.ts b/B05_Profile/B05_Profile_UI_RouteEdit.ts index e6ea19bb8..b2a88106c 100644 --- a/B05_Profile/B05_Profile_UI_RouteEdit.ts +++ b/B05_Profile/B05_Profile_UI_RouteEdit.ts @@ -67,7 +67,7 @@ import { routeLocksOf, writeRouteLocks, } from "./B05_Profile_UI_RouteEdit_Lock"; -import { createInitialPanel } from "./B05_Profile_UI_RouteEdit_Initial"; +import { createInitialPanel, type InitialPanelParams } from "./B05_Profile_UI_RouteEdit_Initial"; import { createProfileStrip } from "./B05_Profile_UI_RouteEdit_ProfileStrip"; import { applyArcLocks, @@ -104,6 +104,8 @@ export interface RouteEditOptions { smooth?: boolean; /** 머리 도구줄 끝에 앉힐 부른 쪽 단추([3D] 등). */ tools?: HTMLElement[]; + /** 좌측 「노선 추천」 칸 요소 — [노선 추천] · [초기화] · 현재 추천(345-4). */ + recommend: InitialPanelParams["controls"]; } /** 편집기가 사는 보기 — 보임 · 숨김 · 떠남. */ @@ -115,7 +117,7 @@ export async function mountRouteEditor( host: RouteEditHost, projectId: string, onApplied: () => void | Promise, - options: RouteEditOptions = {}, + options: RouteEditOptions, ): Promise { const stationIntervalM = options.stationIntervalM ?? 20; const chrome = createRouteEditChrome(parent, options.tools); @@ -269,8 +271,9 @@ export async function mountRouteEditor( }; /** 초기노선 갈래 — 로직 단추 · [초기화] · 비교값(계획서 20). 서버 묻기는 `_Initial` 몫. */ const initial = createInitialPanel({ - overlay, + controls: options.recommend, projectId, + planned: () => planned, planMinRadiusM: () => minRadiusM, apply: (snapshot, message) => restoreSnapshot(snapshot, message, true), }); diff --git a/B05_Profile/B05_Profile_UI_RouteEdit_Chrome.ts b/B05_Profile/B05_Profile_UI_RouteEdit_Chrome.ts index 0148d3de1..96dabba3e 100644 --- a/B05_Profile/B05_Profile_UI_RouteEdit_Chrome.ts +++ b/B05_Profile/B05_Profile_UI_RouteEdit_Chrome.ts @@ -13,7 +13,8 @@ import { createButton } from "@ui/ui_template_elements"; * * 자리(345-3) — 모달이 아니라 종단 보기 가운데 위 본문(옛 3D 자리)에 붙는다. 덮개 · 전체 화면 · * 닫기 단추는 걷었다 · [취소] → [반영 전으로] · [확인] → [노선 반영] · 오른쪽 횡단 칸은 접는 칸. - * 머리 끝 `tools` 자리에 부른 쪽 단추([3D])가 앉는다. + * 머리 `tools` 자리에 부른 쪽 단추([3D])가 앉는다. [노선 추천] · 현재 추천 · [초기화]는 좌측 + * 「노선 추천」 칸으로 옮겼다(345-4 · `_Panel_Recommend`). * ========================================================================== */ /** 회전 단추 아이콘 — **반만 도는 화살표**(2026-09-12 사용자 지시 ㉙). 한 바퀴를 다 그린 @@ -53,11 +54,8 @@ export function createRouteEditChrome( 계획노선 - - 노선 추천 분석 지형 @@ -79,8 +77,6 @@ export function createRouteEditChrome( title="되돌리기 (Ctrl+Z)" disabled>↶ 되돌리기 ↷ 다시하기 - 초기화 반영 전으로 Array<[number, number]>; /** `/route/plan` 이 준 최소 R — 서버 갈래 기준값이 오기 전 보일 값. */ planMinRadiusM: () => number; /** 받은 노선을 편집값으로 싣는다 — 되돌리기 한 걸음으로 쌓인다. */ @@ -75,10 +83,8 @@ const copy = (snapshot: RouteEditSnapshot): RouteEditSnapshot => JSON.parse(JSON.stringify(snapshot)) as RouteEditSnapshot; export function createInitialPanel(params: InitialPanelParams): InitialPanel { - const { overlay, projectId } = params; - const recommendButton = overlay.querySelector('[data-act="recommend"]')!; - const resetButton = overlay.querySelector('[data-act="reset"]')!; - const nowLine = overlay.querySelector("[data-rec-now]")!; + const { projectId } = params; + const { recommendButton, resetButton, nowLine } = params.controls; /** 창을 열 때 캐시에 있던 추천 — [취소]가 돌아갈 자리. */ let openingChoice = readInitialChoice(projectId); @@ -146,7 +152,7 @@ export function createInitialPanel(params: InitialPanelParams): InitialPanel { logic: (choice ?? base).logic, planMinRadiusM: params.planMinRadiusM(), expected: (plan?.expected ?? []).map(([x, y]) => [x, y]), - current: opening!.planned.map(([x, y]) => [x, y]), + current: params.planned().map(([x, y]) => [x, y]), request, onApply: (picked) => { choice = picked; diff --git a/B05_Profile/B05_Profile_UI_RouteEdit_Recommend.ts b/B05_Profile/B05_Profile_UI_RouteEdit_Recommend.ts index d42d94aef..edd9c9298 100644 --- a/B05_Profile/B05_Profile_UI_RouteEdit_Recommend.ts +++ b/B05_Profile/B05_Profile_UI_RouteEdit_Recommend.ts @@ -557,7 +557,8 @@ export function openRecommendModal(params: RecommendParams): void { }, }); - // 편집 창 단축키(Ctrl+Z 등)가 뒤에서 노선을 되돌리지 않게 막는다. 칸 안의 글자 되돌리기는 그대로. + // 본문 평면 · 종단도 단축키(Ctrl+Z 등 · 창에서 들음)가 뒤에서 되돌리지 않게 막는다 — 칸 안 글자 + // 되돌리기는 그대로(345-4 · 이제 모달은 이 창 하나). (bodyEl as HTMLElement | null) ?.closest(".ui-modal__backdrop") ?.addEventListener("keydown", (event) => { diff --git a/B05_Profile/B05_Profile_UI_Style_RouteRecommend.css b/B05_Profile/B05_Profile_UI_Style_RouteRecommend.css index 5eef6bf43..301a55451 100644 --- a/B05_Profile/B05_Profile_UI_Style_RouteRecommend.css +++ b/B05_Profile/B05_Profile_UI_Style_RouteRecommend.css @@ -1,15 +1,20 @@ /* 노선 추천 모달(계획서 23-2) — 알약 넷 · 미리 보기 지도 · 비중 · 제약 칸 · 비교값. - 색은 전부 테마 토큰을 쓴다(밝음 · 어두움). 계획노선 편집 창(`.b05-routeedit`) 위에 뜬다. */ + 색은 전부 테마 토큰을 쓴다(밝음 · 어두움). 종단 보기 화면 위에 뜬다(모달 하나 · 345-4). */ -/* 편집 창 제목 옆 「현재 추천: ○○」. */ +/* 좌측 「노선 추천」 칸(345-4) — 「현재 추천: ○○」 + [노선 추천] [초기화]. */ .b05-routeedit__rec-now { flex: none; color: var(--color-text-secondary); font-size: var(--text-caption); - white-space: nowrap; } -/* 곡선 라벨 패널(`.b05-routeedit__label` = 모달 + 1) 위로 — 모달과 뒤 막이 함께 올라간다. */ +.b05-route__rec-actions { + display: flex; + flex-wrap: wrap; + gap: var(--spacing-8); +} + +/* 곡선 라벨 패널 · 페이지 막들 위로 — 모달과 뒤 막이 함께 올라간다. */ .ui-modal__backdrop.b05-routerec-backdrop { z-index: calc(var(--z-modal, 1000) + 2); } diff --git a/resources/tester/helper_311_panel_folded.cjs b/resources/tester/helper_311_panel_folded.cjs index 69dc88514..b7d2d8bbe 100644 --- a/resources/tester/helper_311_panel_folded.cjs +++ b/resources/tester/helper_311_panel_folded.cjs @@ -176,6 +176,7 @@ const panel = design.createDesignPanel( parts: { structures: { root: node("section"), listRoot: node() }, settings: { nodes: [text("a")] }, + recommend: { nodes: [text("rec")] }, crossView: { row: text("cross-row") }, standardSlot: text("std"), }, diff --git a/resources/tester/helper_design_view_parity.cjs b/resources/tester/helper_design_view_parity.cjs index 840f6d142..14d3a32da 100644 --- a/resources/tester/helper_design_view_parity.cjs +++ b/resources/tester/helper_design_view_parity.cjs @@ -197,6 +197,7 @@ const built = panel.createDesignPanel( parts: { structures: sample(), settings: { nodes: [text("a"), text("b")] }, + recommend: { nodes: [text("rec")] }, crossView: { row: text("cross-row") }, standardSlot: text("std"), }, diff --git a/resources/tester/test_311_panel_folded.py b/resources/tester/test_311_panel_folded.py index 24290e4b0..7b4aaf40a 100644 --- a/resources/tester/test_311_panel_folded.py +++ b/resources/tester/test_311_panel_folded.py @@ -42,6 +42,7 @@ def out(): def test_B05_B06_첫_그림_접힘(out): assert out["profileFirst"] == { "페이지 설정": True, + "노선 추천": False, # 345-4 단추 칸 — 펼친 채 } assert out["crossFirst"]["B06_View_Title"] is True assert out["crossFirst"]["B06_Std_Title"] is True # 옛 기본 접힘 그대로 diff --git a/resources/tester/test_345_4_recommend_panel.py b/resources/tester/test_345_4_recommend_panel.py new file mode 100644 index 000000000..341579d3a --- /dev/null +++ b/resources/tester/test_345_4_recommend_panel.py @@ -0,0 +1,61 @@ +"""345-4 노선 추천 → 좌측 새 칸 「노선 추천」(지운 「계획노선」 자리) — [노선 추천] · 현재 추천 · [초기화]. + +· 칸 요소는 `_Panel_Recommend` 가 만들고 편집기(`_RouteEdit_Initial`)가 밖에서 받아 건다 +· 추천 창은 공용 모달 하나 · [적용] = 본문 평면에 되돌리기 한 칸(`restoreSnapshot(…, true)` → 이력 한 걸음) +· 추천 창의 「지금 노선」은 본문 편집기 지금 노드 +칸 생김새 · 차례는 `test_design_view_parity` · `test_311_panel_folded` 가 봄(화면 확인은 브레인). +""" + +from __future__ import annotations + +from pathlib import Path + +ROOT = Path(__file__).resolve().parents[2] + + +def _read(rel: str) -> str: + return (ROOT / rel).read_text(encoding="utf-8") + + +def test_칸_자리와_재료(): + sections = _read("B05_Profile/B05_Profile_UI_Panel_Sections.ts") + at = sections.index('id: "route-recommend"') + block = sections[at : at + 300] + assert 'title: "노선 추천"' in block and "order: 30," in block # 지운 계획노선 자리 + assert 'part(ctx, "recommend").nodes' in block + panel = _read("B05_Profile/B05_Profile_UI_Panel.ts") + assert "const recommend = createRecommendPart();" in panel + assert "Object.assign(shared.parts, { settings, recommend });" in panel + view = _read("A00_Common/design/design_view_profile.ts") + assert "recommend: panel.recommend," in view + + +def test_편집기가_칸_요소를_밖에서_받음(): + initial = _read("B05_Profile/B05_Profile_UI_RouteEdit_Initial.ts") + assert "overlay" not in initial + assert "const { recommendButton, resetButton, nowLine } = params.controls;" in initial + assert "current: params.planned().map(([x, y]) => [x, y])," in initial # 지금 노선 + main = _read("B05_Profile/B05_Profile_UI_RouteEdit.ts") + assert "controls: options.recommend," in main + assert "planned: () => planned," in main + assert len(main.splitlines()) <= 700 + + +def test_적용은_본문_평면에_되돌리기_한_칸(): + main = _read("B05_Profile/B05_Profile_UI_RouteEdit.ts") + assert "apply: (snapshot, message) => restoreSnapshot(snapshot, message, true)," in main + restore = main[main.index("function restoreSnapshot(") :] + restore = restore[: restore.index("\n }\n")] + assert "applyEdit(message, record);" in restore + apply_edit = main[main.index("function applyEdit(") :] + assert "if (record) history?.commit(snapshotNow());" in apply_edit[:600] + initial = _read("B05_Profile/B05_Profile_UI_RouteEdit_Initial.ts") + on_apply = initial[initial.index("onApply: (picked) => {") :] + assert on_apply[:500].count("params.apply(") == 1 + + +def test_추천_창은_공용_모달_하나(): + rec = _read("B05_Profile/B05_Profile_UI_RouteEdit_Recommend.ts") + assert rec.count("openModal(") == 1 + # 뒤 판(평면 · 종단도) 단축키가 창 안 Ctrl+Z 를 받지 않게 — 창 막에서 끊음 + assert '?.closest(".ui-modal__backdrop")' in rec and "key.stopPropagation();" in rec diff --git a/resources/tester/test_b05_routeedit_initial.py b/resources/tester/test_b05_routeedit_initial.py index c6c896877..ae7d1c855 100644 --- a/resources/tester/test_b05_routeedit_initial.py +++ b/resources/tester/test_b05_routeedit_initial.py @@ -167,13 +167,18 @@ def test_미리_보기_지도_선(results): def test_도구_막대는_노선_추천_하나만(): - """편집 창 도구 막대 — [노선 추천] 하나 · 추천 묶음(로직 단추 · 슬라이더 · 제약 칸) 없음 · - 「연 상태로」 없음. 그 묶음은 모달(`_Recommend`)로 옮겼다.""" + """[노선 추천] 하나 · 추천 묶음(로직 단추 · 슬라이더 · 제약 칸) 없음 · 「연 상태로」 없음. 그 묶음은 + 모달(`_Recommend`)로 옮겼다. 345-4 — [노선 추천] · [초기화] · 현재 추천은 편집 머리에서 좌측 + 「노선 추천」 칸(`_Panel_Recommend`)으로 옮김.""" page = os.path.join(HERE, "..", "..", "B05_Profile") chrome = open(os.path.join(page, "B05_Profile_UI_RouteEdit_Chrome.ts"), encoding="utf-8").read() - assert chrome.count('data-act="recommend"') == 1 + for gone in ('data-act="recommend"', 'data-act="reset"', "data-rec-now"): + assert gone not in chrome assert "연 상태로" not in chrome and "history-reset" not in chrome - assert chrome.index('data-act="recommend"') < chrome.index('data-act="contour-minor"') + part = open(os.path.join(page, "B05_Profile_UI_Panel_Recommend.ts"), encoding="utf-8").read() + assert part.count('data-act="recommend" disabled') == 1 # 단추 하나 + assert part.count('data-act="reset" disabled') == 1 + assert part.index('data-act="recommend" disabled') < part.index('data-act="reset" disabled') initial = open( os.path.join(page, "B05_Profile_UI_RouteEdit_Initial.ts"), encoding="utf-8" ).read() @@ -203,9 +208,10 @@ def test_노선_추천_다듬기_23_3(): return open(os.path.join(page, name), encoding="utf-8").read() chrome = read("B05_Profile_UI_RouteEdit_Chrome.ts") - rec = chrome.index('data-act="recommend"') - divider = chrome.index("b05-routeedit__divider", rec) - assert rec < divider < chrome.index('data-act="contour-minor"') + # 345-4 — [노선 추천]은 좌측 칸 · 머리 구분선은 [3D](tools) 뒤 · 되돌리기 앞 둘 그대로 + tools = chrome.index('data-slot="tools"') + divider = chrome.index("b05-routeedit__divider", tools) + assert tools < divider < chrome.index('data-act="contour-minor"') assert chrome.count("b05-routeedit__divider") == 2 shared = read("B05_Profile_UI_RouteEdit_Recommend_Map.ts") for name in ("bindRouteEditNavigation", "createMapRotation", "fitCanvas", "metricToScreen"): diff --git a/resources/tester/test_design_view_parity.py b/resources/tester/test_design_view_parity.py index 4a7a636cf..7618fb22d 100644 --- a/resources/tester/test_design_view_parity.py +++ b/resources/tester/test_design_view_parity.py @@ -59,8 +59,8 @@ def results(): def test_보기를_오가도_좌측_패널_단추_구조물_배치는_같은_노드(results): assert results["sameNodes"] == {"cross": True, "back": True} # 보기마다 새로 만들지 않는다 — 칸은 처음 보일 때 한 번만 서고 뒤로는 숨김 · 보임만. - # 아래 고정 영역 + 종단 칸 둘 + 횡단 칸 둘(345-2 선택 포인트 · 345-3 계획노선 칸 지움) - assert results["childCount"] == 1 + 2 + 2 + # 아래 고정 영역 + 종단 칸 셋 + 횡단 칸 둘(345-2 선택 포인트 · 345-3 계획노선 지움 · 345-4 노선 추천) + assert results["childCount"] == 1 + 3 + 2 def test_구조물_배치는_두_보기_모두_맨_위_같은_DOM(results): @@ -80,6 +80,7 @@ def test_보기마다_제_칸만_보인다(results): assert views["profile"]["shownTitles"] == [ "구조물 배치", "페이지 설정", + "노선 추천", ] assert views["cross"]["shownTitles"] == ["구조물 배치", "횡단 보기 설정", "표준 횡단면 설정"] assert views["back"]["shownTitles"] == views["profile"]["shownTitles"]