From 2c520537b667d69df6c1b064e2bfef36ec6e9b33 Mon Sep 17 00:00:00 2001 From: umsangdon Date: Tue, 29 Sep 2026 20:17:35 +0900 Subject: [PATCH] =?UTF-8?q?refactor(B05=C2=B7B06):=20=EC=A2=8C=EC=B8=A1=20?= =?UTF-8?q?=EC=B9=B8=EC=9D=84=20=EB=93=B1=EB=A1=9D=EB=B6=80=EB=A1=9C=20?= =?UTF-8?q?=C2=B7=20=EC=95=84=EB=9E=98=20=EB=8B=A8=EC=B6=94=20=EB=84=B7=20?= =?UTF-8?q?=C2=B7=20B05=20Panel=20=EB=82=98=EB=88=94(24-2b)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - B05 좌측 패널(714줄)을 Fields · Settings · Route · View · Sections 로 나누고 createRoutePanel 은 등록부로 세움 - B06 좌측 칸(횡단 보기 설정 · 표준 횡단면 설정)을 등록부로 · 페이지는 createDesignPanel 만 부름 - 「구조물 배치」는 design_sections 한 정의로 두 보기 - 아래 단추 [초기화][저장][확정][보기 전환] — B05 [확정]은 저장 흐름 이음 전까지 꺼 둠 - [초기화] 는 design_reset 한 함수를 두 보기가 부름 - 칸 DOM 은 고치기 전과 같음(전후 DOM 대조) Co-Authored-By: Claude Sonnet 5.5 Claude-Session: https://claude.ai/code/session_01EauA2ATmqNRbeV48s9CcBr --- A00_Common/design/design_dock.ts | 6 +- A00_Common/design/design_reset.ts | 59 ++ A00_Common/design/design_sections.ts | 31 + B05_Profile/B05_Profile_UI_Page_Actions.ts | 50 +- B05_Profile/B05_Profile_UI_Panel.ts | 624 +++---------------- B05_Profile/B05_Profile_UI_Panel_Fields.ts | 149 +++++ B05_Profile/B05_Profile_UI_Panel_Route.ts | 131 ++++ B05_Profile/B05_Profile_UI_Panel_Sections.ts | 69 ++ B05_Profile/B05_Profile_UI_Panel_Settings.ts | 234 +++++++ B05_Profile/B05_Profile_UI_Panel_View.ts | 101 +++ B06_Section/B06_Section_UI_Page.ts | 105 ++-- B06_Section/B06_Section_UI_Page_Common.ts | 10 - B06_Section/B06_Section_UI_Page_Panel.ts | 70 +++ 13 files changed, 970 insertions(+), 669 deletions(-) create mode 100644 A00_Common/design/design_reset.ts create mode 100644 A00_Common/design/design_sections.ts create mode 100644 B05_Profile/B05_Profile_UI_Panel_Fields.ts create mode 100644 B05_Profile/B05_Profile_UI_Panel_Route.ts create mode 100644 B05_Profile/B05_Profile_UI_Panel_Sections.ts create mode 100644 B05_Profile/B05_Profile_UI_Panel_Settings.ts create mode 100644 B05_Profile/B05_Profile_UI_Panel_View.ts create mode 100644 B06_Section/B06_Section_UI_Page_Panel.ts diff --git a/A00_Common/design/design_dock.ts b/A00_Common/design/design_dock.ts index cf773b57..bf39f8e8 100644 --- a/A00_Common/design/design_dock.ts +++ b/A00_Common/design/design_dock.ts @@ -103,9 +103,11 @@ export function createDesignDock( (Object.keys(buttons) as Array).forEach((name) => { const action = actions[name]; buttons[name].disabled = action.enabled === false; - buttons[name].title = action.title ?? ""; + if (action.title) buttons[name].title = action.title; + else buttons[name].removeAttribute("title"); }); - switchButton.textContent = switchViewLabel(current); + (switchButton.querySelector(".ui-btn__label") ?? switchButton).textContent = + switchViewLabel(current); row.className = current === "profile" ? "b05-route__actions" : "b06-profile__actions"; }; diff --git a/A00_Common/design/design_reset.ts b/A00_Common/design/design_reset.ts new file mode 100644 index 00000000..25a6e0f6 --- /dev/null +++ b/A00_Common/design/design_reset.ts @@ -0,0 +1,59 @@ +/* ============================================================================= + * design_reset.ts + * [초기화] 한 줄기 — 두 보기의 아래 단추가 **같은 함수**를 부른다(계획서 24-2b). + * + * 사용자 편집 전부 폐기, 계획노선 CSV 기본값으로 B05·B06 재계산 후 재진입. 네이티브 confirm 은 + * 공용 헤드 브라우저가 자동 취소해 버튼이 죽은 듯 보였다(2026-08-19 진단) — 화면 안 모달로 + * 확인받는다. (`B05_Profile_UI_Page_Actions.resetDesignAction` 에서 옮겨 왔다 — 동작 그대로.) + * ========================================================================== */ + +import { ROUTES } from "@config/config_frontend"; +import { currentLanguageIndex, ui_locales } from "@ui/ui_template_locale"; +import { + hideLoadingOverlay, + showConfirmDialog, + showLoadingOverlay, + showToast, +} from "@ui/ui_template_elements"; +import { clearDrafts } from "./../b_page_state"; +import { navigateTo } from "./../router"; +import { clearRouteLatestCache, resetRouteDesign } from "../../B05_Profile/B05_Profile_Api_Fetch"; +import { clearAlignmentDrafts } from "../../B05_Profile/B05_Profile_UI_Profile_Edit"; +import { invalidateSectionDetail } from "../../B06_Section/B06_Section_Section_Store"; + +function L(key: keyof typeof ui_locales): string { + return ui_locales[key][currentLanguageIndex]; +} + +/** + * [초기화] — 확인을 받고, 서버 초기값 복원 뒤 옛 경로 기준 캐시를 전부 비우고 종단 보기로 다시 들어간다. + * `onCleared` 는 페이지만 아는 조작 캐시(상단측 변경분 등)를 비울 자리다. + */ +export async function resetDesign( + projectId: string, + routeId: number | null, + onCleared?: () => void, +): Promise { + if (!(await showConfirmDialog(L("B05_Route_Reset_Confirm"), "초기화"))) return; + showLoadingOverlay(); + try { + await resetRouteDesign(projectId); + // 옛 경로 기준 캐시를 전부 비우고 페이지를 새로 그린다 — 초기 계산본이 정본이 된다. + clearRouteLatestCache(projectId); + invalidateSectionDetail(projectId); + // 사용자 조작(② 초안)은 **등록표 한 곳**에서 통째로 버린다(2026-09-06 일원화). + // 예전에는 파일마다 따로 지워 새 값이 늘 때 빠뜨리기 쉬웠다. 노선 범위 초안은 + // 노선이 바뀌면 키가 달라져 자연히 딸려 오지 않는다. + clearDrafts(projectId, routeId); + onCleared?.(); + // 계획선 편집 초안도 함께 버린다 — 남기면 초기값 위에 옛 편집이 다시 얹혀 계획선이 + // 측점에서 원지반선과 만나지 않는다(2026-09-04 실측: 새로고침 후 최대 6.0m 어긋남). + clearAlignmentDrafts(); + showToast(L("B05_Route_Reset_Success"), "success"); + navigateTo(ROUTES.B05_PROFILE); + } catch (error) { + showToast(error instanceof Error ? error.message : L("B05_Route_Reset_Failed"), "error"); + } finally { + hideLoadingOverlay(); + } +} diff --git a/A00_Common/design/design_sections.ts b/A00_Common/design/design_sections.ts new file mode 100644 index 00000000..644087b0 --- /dev/null +++ b/A00_Common/design/design_sections.ts @@ -0,0 +1,31 @@ +/* ============================================================================= + * design_sections.ts + * 두 보기가 **함께 쓰는 좌측 칸 정의** — 「구조물 배치」(계획서 24-2b). + * + * 한 정의에 `views: ["profile", "cross"]` 를 적어 두 보기가 같은 칸을 같은 자리(맨 위)에 세운다. + * 칸 알맹이는 페이지가 건네는 부품 `structures`(뿌리 · 아래 고정 영역에 얹을 목록 뿌리)다 — + * 종단 보기는 배치 콜백, 횡단 보기는 거기에 횡단 조정 콜백을 더해 만들지만 **칸 정의는 하나**다. + * ========================================================================== */ + +import { part, registerPanelSection } from "./design_panel"; + +/** 페이지가 건네는 「구조물 배치」 부품의 모양 — 뿌리와 목록 뿌리만 안다. */ +export interface StructuresPart { + root: HTMLElement; + listRoot: HTMLElement; +} + +export const STRUCTURES_SECTION_ID = "structures"; + +registerPanelSection({ + id: STRUCTURES_SECTION_ID, + title: "구조물 배치", + views: ["profile", "cross"], + order: 10, + build: (ctx) => { + const structures = part(ctx, "structures"); + // 구조물 목록은 「구조물 배치」 본문이 아니라 아래 고정 영역에 놓는다 — 본문에 두면 + // 고른 구조물의 폼 길이에 밀려 화면 밖으로 나갔다(2026-08-18 사용자 지시). + return { root: structures.root, dock: structures.listRoot }; + }, +}); diff --git a/B05_Profile/B05_Profile_UI_Page_Actions.ts b/B05_Profile/B05_Profile_UI_Page_Actions.ts index 8b822cad..6d1c8aee 100644 --- a/B05_Profile/B05_Profile_UI_Page_Actions.ts +++ b/B05_Profile/B05_Profile_UI_Page_Actions.ts @@ -7,22 +7,10 @@ * 세션에 쌓인 조작은 [저장]·[확정]에서만 정본으로 나간다). * ========================================================================== */ -import { ROUTES } from "@config/config_frontend"; import { currentLanguageIndex, ui_locales } from "@ui/ui_template_locale"; -import { - hideLoadingOverlay, - showConfirmDialog, - showLoadingOverlay, - showToast, -} from "@ui/ui_template_elements"; -import { navigateTo } from "../A00_Common/router"; -import { - clearRouteLatestCache, - confirmRoute, - resetRouteDesign, - solveRoute, - type RouteLatestResponse, -} from "./B05_Profile_Api_Fetch"; +import { hideLoadingOverlay, showLoadingOverlay, showToast } from "@ui/ui_template_elements"; +import { resetDesign } from "../A00_Common/design/design_reset"; +import { confirmRoute, solveRoute, type RouteLatestResponse } from "./B05_Profile_Api_Fetch"; import { flushCulvertOptions } from "../B06_Section/B06_Section_Api_Culvert_Options"; import { flushPendingPipes } from "./B05_Profile_Api_Pipes_Draft"; import { stateKey } from "../A00_Common/b_page_state"; @@ -30,10 +18,8 @@ import { invalidateSectionDetail, saveCachedCrossPatches, } from "../B06_Section/B06_Section_Section_Store"; -import { clearDrafts } from "../A00_Common/b_page_state"; import { saveCorridorIfDirty } from "./B05_Profile_UI_Corridor"; import { circlePoint, routePoint } from "./B05_Profile_UI_Page_Helpers"; -import { clearAlignmentDrafts } from "./B05_Profile_UI_Profile_Edit"; import type { createRoutePanel } from "./B05_Profile_UI_Panel"; import type { createRouteProfilePanel } from "./B05_Profile_UI_Profile_Panel"; import type { createStructuresBridge } from "./B05_Profile_UI_Page_Structures"; @@ -197,30 +183,10 @@ export async function tempSaveAction(ctx: PageActionContext): Promise { } } -/** [초기화] — 사용자 편집 전부 폐기, 계획노선 CSV 기본값으로 B05·B06 재계산 후 재진입. - * 네이티브 confirm은 공용 헤드 브라우저가 자동 취소해 버튼이 죽은 듯 보였다 - * (2026-08-19 진단) — 화면 안 모달로 확인받는다. */ +/** [초기화] — 두 보기가 같은 함수를 부른다(`design_reset` · 계획서 24-2b). 페이지만 아는 + * 상단측 변경분만 여기서 비운다. */ export async function resetDesignAction(ctx: PageActionContext): Promise { - if (!(await showConfirmDialog(L("B05_Route_Reset_Confirm"), "초기화"))) return; - showLoadingOverlay(); - try { - await resetRouteDesign(ctx.projectId); - // 옛 경로 기준 캐시를 전부 비우고 페이지를 새로 그린다 — 초기 계산본이 정본이 된다. - clearRouteLatestCache(ctx.projectId); - invalidateSectionDetail(ctx.projectId); - // 사용자 조작(② 초안)은 **등록표 한 곳**에서 통째로 버린다(2026-09-06 일원화). - // 예전에는 파일마다 따로 지워 새 값이 늘 때 빠뜨리기 쉬웠다. 노선 범위 초안은 - // 노선이 바뀌면 키가 달라져 자연히 딸려 오지 않는다. - clearDrafts(ctx.projectId, ctx.latest()?.route?.id ?? null); - ctx.uphillOverrides.clear(); - // 계획선 편집 초안도 함께 버린다 — 남기면 초기값 위에 옛 편집이 다시 얹혀 계획선이 - // 측점에서 원지반선과 만나지 않는다(2026-09-04 실측: 새로고침 후 최대 6.0m 어긋남). - clearAlignmentDrafts(); - showToast(L("B05_Route_Reset_Success"), "success"); - navigateTo(ROUTES.B05_PROFILE); - } catch (error) { - showToast(error instanceof Error ? error.message : L("B05_Route_Reset_Failed"), "error"); - } finally { - hideLoadingOverlay(); - } + await resetDesign(ctx.projectId, ctx.latest()?.route?.id ?? null, () => + ctx.uphillOverrides.clear(), + ); } diff --git a/B05_Profile/B05_Profile_UI_Panel.ts b/B05_Profile/B05_Profile_UI_Panel.ts index 2480ac40..b72c7f06 100644 --- a/B05_Profile/B05_Profile_UI_Panel.ts +++ b/B05_Profile/B05_Profile_UI_Panel.ts @@ -1,88 +1,29 @@ -import type { PlacedRoutePoint, RoutePointKind } from "./B05_Profile_UI_Markers"; +/* ============================================================================= + * B05_Profile_UI_Panel.ts + * 종단 보기(B05) 좌측 패널 — 칸 재료를 모아 **공용 등록부**(`design_panel`)에 넘겨 세운다. + * + * 칸 정의는 `_Panel_Sections`(종단 보기 전용) · `design_sections`(두 보기 공용), 재료는 + * `_Panel_Settings` · `_Panel_Route` · `_Panel_View`, 값 · 상수 · 입력 칸 만들기는 `_Panel_Fields` + * 에 있다(700줄 제한으로 나눔, 계획서 24-2b). 값을 읽고 되살리는 창구(`values` · `restore`)만 + * 여기 남는다. 아래 고정 영역(단추 넷)은 `design_dock` 이 만든다. + * ========================================================================== */ + +import type { PlacedRoutePoint } from "./B05_Profile_UI_Markers"; import type { StructureInstance } from "./B05_Profile_Api_Structures"; import type { FacilityAttributes } from "./B05_Profile_UI_Drainage_Facility"; import { createStructuresSection, type StructuresSection } from "./B05_Profile_UI_Structures_Panel"; -import { type ButtonVariant, createButton, createSelectField } from "@ui/ui_template_elements"; -import { attachCollapsible } from "@ui/ui_template_collapsible"; -import { currentLanguageIndex, ui_locales } from "@ui/ui_template_locale"; +import { createDesignPanel } from "../A00_Common/design/design_panel"; +import "./B05_Profile_UI_Panel_Sections"; +import { + STATION_INTERVAL_M, + 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 interface RoutePanelValues { - contourInterval: number; - algorithm: "dijkstra" | "ridge_valley"; - /** 임도 종류 — 현행 3종(간선·산불진화·작업). branch(지선)는 폐지됐으나 기존 - * 저장분 호환을 위해 값으로는 받는다(2026-08-19). 값의 원천은 프로젝트 등록 - * 정보(projects.road_type)이며 화면에서는 고르지 않는다. */ - gradeClass: "trunk" | "fire" | "branch" | "work"; - /** 설계속도(km/h) — 임도는 속도를 낼 수 없는 노선이라 기본 20이다. 간선·산불진화만 - * 20~40에서 고를 수 있고 작업임도는 20 고정(별표2, 2026-08-19 사용자 확정). */ - designSpeed: 20 | 30 | 40; - paved: boolean; - minCurveRadius: number | null; - maxUphillGrade: number | null; - maxDownhillGrade: number | null; - minUphillGrade: number | null; - minDownhillGrade: number | null; - allowAvoidPassThrough: boolean; - stationInterval: number | null; - crossSampleInterval: number | null; - longSampleInterval: number | null; - terrainType: "normal" | "special"; - maxGradePct: number | null; - minVerticalRadius: number | null; - minTangentLength: number | null; - startElevationOffset: number | null; - endElevationOffset: number | null; - /** 횡단배수 최소 계획고를 계획선·편집에 강제할지 — 기본 해제(2026-09-01 사용자 지시). - * 해제해도 부족분 경고는 그대로 뜬다. 강제만 풀고 판단은 사용자에게 맡긴다. */ - enforcePipeClearance: boolean; -} - -/** - * 「임도설치 및 관리 등에 관한 규정」[별표 1-2] 기준값. - * 등급을 바꾸면 이 값이 계획선 폼의 placeholder(미입력 시 서버 기본값)로 반영된다. - * 실제 기본값 결정은 서버 config가 단일 소스이며 여기서는 안내만 한다. - */ -/** 측점 간격(m) — 20m 고정(2026-08-18 사용자 확정, 입력 UI 삭제). */ -const STATION_INTERVAL_M = 20; - -/* 종단기울기·종단곡선 반경 — 지식DB `설계제원_총괄 §6·§7`(간선·산불진화 공통, - * 설계속도 40/30/20 → 7·10 / 8·12 / 9·14 %, 반경 450/250/100 m) 값과 일치한다 - * (2026-08-19 재점검). 서버 `legal_grade_limit_pct`가 같은 표를 쓰므로 화면 안내와 - * 실제 적용값이 어긋나지 않는다. - * - * ⚠ 미해결(사용자 협의 필요, 2026-08-19 재점검에서 확인): - * ① 등급 선택지가 간선·지선·작업인데 현행 규칙은 **지선 폐지**, 3종은 간선· - * 산불진화·작업이다(설계제원_총괄 §1 [구현] 지침). - * ② 지식DB의 40/30/20 표는 **설계속도**별이고 간선·산불진화 공통이다. 지금은 - * 등급에 속도를 고정 매핑(trunk=40·branch=30·work=20)했는데, 작업임도는 - * 설계속도 20 이하 + 종단기울기 최대 20% 범위 조정이라는 별도 규정이다. - * ③ "최소 곡선반경" 입력 칸의 안내값이 **종단**곡선 반경(450/250/100)이다. - * 평면 최소곡선반지름은 일반지형 60/30/15 (설계제원_총괄 §4). - * 값을 임의로 바꾸지 않고 남긴다 — 기본값 확정은 사용자 협의 영역이다. */ -const PROFILE_CRITERIA: Record< - RoutePanelValues["designSpeed"], - { grade: Record; radius: number } -> = { - 40: { grade: { normal: 7, special: 10 }, radius: 450 }, - 30: { grade: { normal: 8, special: 12 }, radius: 250 }, - 20: { grade: { normal: 9, special: 14 }, radius: 100 }, -}; - -/** 임도 종류별 표시 이름 — 프로젝트 등록(B02)과 같은 말을 쓴다(2026-08-19 확정). */ -const ROAD_TYPE_LABELS: Record = { - trunk: "간선임도", - fire: "산불진화임도", - work: "작업임도", - branch: "작업임도(구 지선)", -}; - -/** 종류별로 고를 수 있는 설계속도 — 작업임도는 20 이하라 20만 둔다(별표2 Ⅰ.3). */ -const SPEED_CHOICES: Record> = { - trunk: [20, 30, 40], - fire: [20, 30, 40], - work: [20], - branch: [20], -}; +export type { RoutePanelValues } from "./B05_Profile_UI_Panel_Fields"; interface PanelCallbacks { onSolve: () => void; @@ -136,372 +77,35 @@ interface PanelCallbacks { onStationDisplayChange: (offset: { station: number; cumulative: number }) => void; } -type WrappedInput = HTMLInputElement & { wrapper: HTMLLabelElement }; - -function L(key: keyof typeof ui_locales): string { - return ui_locales[key][currentLanguageIndex]; -} - -function section(title: string, collapsed = false): { root: HTMLElement; body: HTMLElement } { - const root = document.createElement("section"); - // ui-sidebar-section: 사이드 컨테이너 공통 외곽선(진하게, 2026-08-05 사용자 지시). - // 접힘 초기값만 여기서 준다 — 펼치기·접기 토글은 ui_template_collapsible 전역 몫. - root.className = `b05-route__panel-section ui-collapsible ui-sidebar-section${collapsed ? " is-collapsed" : ""}`; - const heading = document.createElement("h3"); - heading.className = "ui-collapsible__title"; - heading.textContent = title; - const body = document.createElement("div"); - body.className = "b05-route__panel-body"; - root.append(heading, body); - return { root, body }; -} - -function button( - label: string, - onClick: () => void, - variant: ButtonVariant = "ghost", -): HTMLButtonElement { - return createButton({ label, variant, onClick: () => onClick() }); -} - -function numberField(label: string, value = ""): WrappedInput { - const wrapper = document.createElement("label"); - wrapper.className = "b05-route__field"; - const caption = document.createElement("span"); - caption.textContent = label; - const input = document.createElement("input"); - input.type = "number"; - input.step = "0.01"; - input.value = value; - wrapper.append(caption, input); - return Object.assign(input, { wrapper }); -} - -function checkbox(label: string, checked: boolean): WrappedInput { - const wrapper = document.createElement("label"); - wrapper.className = "b05-route__check"; - const input = document.createElement("input"); - input.type = "checkbox"; - input.checked = checked; - wrapper.append(input, document.createTextNode(label)); - return Object.assign(input, { wrapper }); -} - -function toggleButton( - label: string, - checked: boolean, - onChange: (checked: boolean) => void, -): HTMLButtonElement { - const element = button( - label, - () => { - const active = !element.classList.contains("is-active"); - element.classList.toggle("is-active", active); - element.setAttribute("aria-pressed", String(active)); - onChange(active); - }, - "glass", - ); - element.classList.toggle("is-active", checked); - element.setAttribute("aria-pressed", String(checked)); - return element; -} - -function parseOptional(input: HTMLInputElement): number | null { - if (!input.value.trim()) return null; - const value = Number(input.value); - return Number.isFinite(value) ? value : null; -} - export function createRoutePanel(callbacks: PanelCallbacks) { - const root = document.createElement("div"); - root.className = "b05-route__panel"; - - const viewControls = document.createElement("div"); - viewControls.className = "b05-route__view-controls"; - const viewButtons = document.createElement("div"); - viewButtons.className = "b05-route__view-group"; - (["iso", "top", "front", "side"] as const).forEach((preset) => - viewButtons.append(button(preset.toUpperCase(), () => callbacks.onView(preset), "glass")), - ); - // 직교/원근 전환(2026-09-04 사용자 지시) — 기본은 원근이고, 단추 글자는 **바뀔 쪽**을 - // 가리킨다(누르면 그쪽으로 간다). - let projection: "perspective" | "ortho" = "perspective"; - const projectionButton = button( - "직교로", - () => { - projection = projection === "perspective" ? "ortho" : "perspective"; - projectionButton.textContent = projection === "perspective" ? "직교로" : "원근으로"; - callbacks.onProjection(projection); - }, - "glass", - ); - viewButtons.append(projectionButton); - const visibilityButtons = document.createElement("div"); - visibilityButtons.className = "b05-route__view-group"; - visibilityButtons.append( - toggleButton("지표면", true, callbacks.onSurfaceVisible), - // 예상형상(2026-08-23): ON=공사 후 형상(기본), OFF=원지반 완전체. - toggleButton("예상형상", true, callbacks.onCorridorVisible), - toggleButton("등고선", true, callbacks.onContoursVisible), - toggleButton("축 표시", false, callbacks.onAxesVisible), - toggleButton(L("B05_Route_Field_StationLines"), true, callbacks.onStationLinesVisible), - toggleButton(L("B05_Route_Field_StationLabels"), true, callbacks.onStationLabelsVisible), - // 무지개 고도색이 헷갈릴 때 명도만 남기는 흑백 표시(2026-08-05 사용자 요청). - // 기본 켜짐(2026-08-23 사용자 확정) — 예상형상 색이 지형 고도색에 묻히지 않는다. - toggleButton("흑백 지형", true, callbacks.onSurfaceGrayscale), - ); - // [3D 업데이트](2026-09-01 사용자 지시) — 계획선을 고칠 때마다 3D가 따라오면 조작이 - // 무거워, 갱신을 이 버튼으로 모았다. 편집이 쌓이면 is-pending으로 알리고, 누르는 동안은 - // 이 버튼만 잠근다(화면 전체 잠금 금지 — CLAUDE.md 5장). - const corridorRefresh = button( - "3D 업데이트", - () => { - corridorRefresh.disabled = true; - corridorRefresh.textContent = "갱신 중…"; - void Promise.resolve(callbacks.onCorridorRefresh()).finally(() => { - corridorRefresh.disabled = false; - corridorRefresh.textContent = "3D 업데이트"; - corridorRefresh.classList.remove("is-pending"); - }); - }, - "glass", - ); - corridorRefresh.title = - "계획선 편집을 3D 예상형상·측점선에 반영합니다.\n편집이 쌓이면 버튼에 표시가 붙습니다."; - const separator1 = document.createElement("span"); - separator1.className = "b05-route__view-separator"; - const separator2 = separator1.cloneNode() as HTMLSpanElement; - const separator3 = separator1.cloneNode() as HTMLSpanElement; - viewControls.append( - viewButtons, - separator1, - visibilityButtons, - separator2, - button("뷰 초기화", callbacks.onResetView, "glass"), - separator3, - corridorRefresh, - ); - - // 등고선 간격 행 — 「페이지 설정」 섹션에 들어간다(2026-08-18 컨테이너 병합). - const contourInterval = numberField("간격 (m), 최소 0.5m", "1"); - const contourRow = document.createElement("div"); - contourRow.className = "b05-route__contour-row"; - // 등고선 재적용은 화면 전체를 잠그지 않는다(CLAUDE.md 5장) — 누른 버튼만 잠가 - // 진행을 알리고, 나머지 조작은 그대로 열어 둔다. - const contourApply = button("재적용", () => { - contourApply.disabled = true; - contourApply.textContent = "적용 중…"; - void Promise.resolve(callbacks.onContourApply(Number(contourInterval.value) || 1)).finally( - () => { - contourApply.disabled = false; - contourApply.textContent = "재적용"; - }, - ); - }); - contourRow.append(contourInterval.wrapper, contourApply); - - // 포인트 팔레트 + 임도 기준·옵션을 한 컨테이너로 병합하고 [최적 경로 계산]도 이 안에 - // 둔다 — 경로 재탐색은 B04~B06 재계산을 부르는 무거운 작업이라 가끔만 쓴다 - // (2026-08-08 사용자 지시). - // 계획노선 편집 — 노선을 고치는 유일한 입구(2026-09-06 사용자 확정). 자동탐색 - // (「경로 계산 설정」)은 사용 중단이라 이 자리가 노선을 바꾸는 길이다. - const plannedRoute = section("계획노선"); - const plannedRouteBtn = document.createElement("button"); - plannedRouteBtn.type = "button"; - plannedRouteBtn.className = "b05-route__btn"; - plannedRouteBtn.textContent = "계획노선 편집"; - plannedRouteBtn.title = "예상노선 위에서 계획노선을 고칩니다. [확인] 때만 다시 계산합니다."; - plannedRouteBtn.addEventListener("click", () => callbacks.onEditPlannedRoute()); - plannedRoute.body.append(plannedRouteBtn); - - const routeCalc = section("경로 계산 설정"); - routeCalc.root.classList.add("is-collapsed"); - const paletteGrid = document.createElement("div"); - paletteGrid.className = "b05-route__palette"; - const pointLabels: Record = { - bp: "BP 시작점", - ep: "EP 종료점", - cp: "CP 경유점", - ap: "AP 회피구역", - fp: "FP 금지구역", - }; - (Object.keys(pointLabels) as RoutePointKind[]).forEach((kind) => { - const chip = document.createElement("div"); - chip.className = `b05-route__chip is-${kind}`; - chip.draggable = true; - chip.textContent = pointLabels[kind]; - chip.addEventListener("dragstart", (event) => event.dataTransfer?.setData("pointType", kind)); - paletteGrid.append(chip); - }); - routeCalc.body.append(paletteGrid); - - const selected = section("선택 포인트 상세 설정"); - selected.root.hidden = true; - const selectedName = document.createElement("strong"); - const radius = numberField("회피/금지 반경 (m)", "25"); - radius.addEventListener("change", () => callbacks.onRadiusChange(Number(radius.value) || 1)); - const selectedActions = document.createElement("div"); - selectedActions.className = "b05-route__actions"; - selectedActions.append( - button("위치 이동", callbacks.onMovePoint), - button("삭제", callbacks.onDeletePoint, "danger"), - ); - selected.body.append(selectedName, radius.wrapper, selectedActions); - - // 드롭다운은 공통 컴포넌트(createSelectField) 재사용. `.select`로 받아 이하 로직 불변. - const algorithmField = createSelectField({ - label: "알고리즘", - options: [ - { value: "dijkstra", text: "Dijkstra" }, - { value: "ridge_valley", text: "능선·계곡" }, - ], - }); - const algorithm = algorithmField.select; - // 임도 종류는 **프로젝트 등록(B02)에서 정한 값**을 그대로 쓴다 — 여기서 고치는 - // 값이 아니다(2026-08-19 사용자 확정). 화면에는 읽기 전용으로 보여만 준다. - const gradeField = createSelectField({ - label: "임도 종류 (프로젝트 등록값)", - options: [ - { value: "trunk", text: ROAD_TYPE_LABELS.trunk }, - { value: "fire", text: ROAD_TYPE_LABELS.fire }, - { value: "work", text: ROAD_TYPE_LABELS.work }, - { value: "branch", text: ROAD_TYPE_LABELS.branch }, - ], - }); - const gradeClass = gradeField.select; - gradeClass.disabled = true; - gradeClass.title = "프로젝트 등록 정보의 임도 종류입니다. 변경은 대시보드에서 하세요."; - // 설계속도 — 임도는 속도를 낼 수 없어 기본 20km/h다. 종류에 따라 선택지가 갈린다. - const speedField = createSelectField({ - label: "설계속도 (km/h)", - options: [20, 30, 40].map((value) => ({ value: String(value), text: `${value} km/h` })), - }); - const designSpeed = speedField.select; - designSpeed.value = "20"; - designSpeed.title = - "종단기울기·종단곡선의 법정 기준은 설계속도로 정해집니다(별표2).\n" + - "간선·산불진화임도는 20~40, 작업임도는 20만 쓸 수 있습니다."; - const minCurveRadius = numberField("최소 곡선반경 (m)"); - const maxUphillGrade = numberField("오르막 경사 상한 (%)"); - const maxDownhillGrade = numberField("내리막 경사 상한 (%)"); - const minUphillGrade = numberField("오르막 경사 하한 (%)"); - const minDownhillGrade = numberField("내리막 경사 하한 (%)"); - const paved = checkbox("포장 임도", false); - const avoidPass = checkbox("회피구역 통과 허용", false); - const details = document.createElement("details"); - const summary = document.createElement("summary"); - summary.textContent = "사용한 조건"; - details.append( - summary, - minCurveRadius.wrapper, - maxUphillGrade.wrapper, - maxDownhillGrade.wrapper, - minUphillGrade.wrapper, - minDownhillGrade.wrapper, - ); - // [최적 경로 계산] 입구는 2026-09-06 사용자 확정으로 화면에서 내렸다 — 평면만 보고 - // 노선을 정하는 방식이라 실제 판단(평면·종단·횡단·유토곡선을 함께 봄)과 맞지 않는다. - // 코드·API 는 그대로 두고 버튼만 뺀다. 노선 변경은 계획노선 편집이 대신한다. - const solveButton = button("최적 경로 계산", callbacks.onSolve, "filled"); - solveButton.hidden = true; - routeCalc.body.append( - algorithmField.root, - paved.wrapper, - avoidPass.wrapper, - details, - solveButton, - ); - - // 「페이지 설정」 — 등고선 간격·시작 측점 및 샘플링·종단 설계 기준 세 섹션 병합 - // (2026-08-18 사용자 확정). 측점 간격 입력은 삭제 — 20m 고정. - const sectionOptions = section("페이지 설정"); - sectionOptions.root.classList.add("is-collapsed"); - const crossSampleInterval = numberField(L("B05_Route_Field_CrossSample")); - const longSampleInterval = numberField(L("B05_Route_Field_LongSample")); - // 샘플 간격 두 칸은 2열 한 행 — 종단이 먼저(2026-08-18 사용자 지시). - const sampleRow = document.createElement("div"); - sampleRow.className = "b05-route__field-row"; - sampleRow.append(longSampleInterval.wrapper, crossSampleInterval.wrapper); - sectionOptions.body.append(sampleRow); - const terrainField = createSelectField({ - label: "지형 구분", - options: [ - { value: "normal", text: "일반지형" }, - { value: "special", text: "특수지형" }, - ], - }); - const terrainType = terrainField.select; - // 기본값 특수지형(2026-09-02 사용자 지시) — 임도 대상지는 대개 특수지형이라 - // 매번 바꿔야 했다. 저장분이 있으면 `apply()`가 그 값으로 덮는다. - terrainType.value = "special"; - // 역기울기(5%) 상한 방향은 서버가 지반 형상에서 자동 판정(main_direction="auto")하므로 - // 수동 선택 UI는 두지 않는다. 노선 균형 구역 길이도 자동 산출 기본값(전체 1구역)에 맡긴다. - // 횡단배수 최소고 강제(2026-09-01 사용자 지시) — 기본 해제. 켜면 자동 계획선이 배관 - // 자리를 시설 여유만큼 들어 올리고, 그 아래로 내리는 편집도 막힌다. 꺼도 부족분 경고는 - // 종단 상단 표시줄에 계속 뜬다. - const enforcePipeClearance = checkbox("횡단배수 최소고 강제", false); - enforcePipeClearance.title = - "켜면 계획선이 배수관·BOX암거·세월교 자리에서 관경(구체 높이)+토피 0.5m 만큼 들립니다.\n" + - "끄면 들어 올리지 않고 부족분은 경고로만 알립니다(기본)."; - const maxGradePct = numberField("최대 종단기울기 (%)"); - const minVerticalRadius = numberField("종단곡선 최소 반경 (m)"); - const minTangentLength = numberField("최소 직선 길이 (m)"); - const startElevationOffset = numberField("시점 계획고 조정 (m)"); - const endElevationOffset = numberField("종점 계획고 조정 (m)"); - const criteriaNote = document.createElement("p"); - criteriaNote.className = "b05-route__note"; - const gradeAdvanced = document.createElement("details"); - const gradeSummary = document.createElement("summary"); - gradeSummary.textContent = "기준값 직접 지정"; - gradeAdvanced.append( - gradeSummary, - maxGradePct.wrapper, - minVerticalRadius.wrapper, - minTangentLength.wrapper, - startElevationOffset.wrapper, - endElevationOffset.wrapper, - ); - // 병합 구획 구분선 — 샘플링 / 등고선 / 종단 설계 기준. - const settingsDivider1 = document.createElement("hr"); - settingsDivider1.className = "b05-structure__divider"; - const settingsDivider2 = settingsDivider1.cloneNode() as HTMLHRElement; - sectionOptions.body.append( - settingsDivider1, - contourRow, - settingsDivider2, - // 임도 종류(등록값) → 설계속도 → 지형 구분 순 — 법정 기준이 정해지는 순서다 - // (2026-08-19 사용자 확정: 종류는 B02에서, 속도·지형은 여기서). - gradeField.root, - speedField.root, - terrainField.root, - criteriaNote, - enforcePipeClearance.wrapper, - gradeAdvanced, - ); - - // 공사 시작 기준 — 이전 공사에 이어 시공할 때 0측점을 임의 측점/누가거리로 시작 표기한다. - // (내부 chainage는 0기준 유지, 측점 라벨·누가거리 "표시"만 이 값만큼 이동.) 기본값 0/0. - // 별도 "공사 시작점" 컨테이너를 없애고 "시작 측점 및 샘플링 설정" 최상단으로 일원화 - // (2026-08-06 사용자 지시). - const startStation = numberField("시작 측점", "0"); - startStation.step = "1"; - startStation.min = "0"; - const startCumulative = numberField("시작 누가거리 (m)", "0"); - // 2열×2행: 1행 항목명 · 2행 값 입력 / 1열 시작 측점 · 2열 시작 누가거리. - const startRow = document.createElement("div"); - startRow.className = "b05-route__field-row"; - startRow.append(startStation.wrapper, startCumulative.wrapper); - sectionOptions.body.prepend(startRow); - const stationDisplayOffset = (): { station: number; cumulative: number } => ({ - station: Math.max(0, Math.round(Number(startStation.value) || 0)), - cumulative: Number(startCumulative.value) || 0, - }); - [startStation, startCumulative].forEach((input) => - input.addEventListener("change", () => - callbacks.onStationDisplayChange(stationDisplayOffset()), - ), - ); + const { viewControls, setCorridorPending } = createViewControls(callbacks); + const settings = createSettingsPart(callbacks); + const route = createRoutePart(callbacks); + const { fields: routeFields } = route; + const { + contourInterval, + gradeClass, + designSpeed, + terrainType, + crossSampleInterval, + longSampleInterval, + maxGradePct, + minVerticalRadius, + minTangentLength, + startElevationOffset, + endElevationOffset, + enforcePipeClearance, + } = settings.fields; + const { + algorithm, + paved, + avoidPass, + minCurveRadius, + maxUphillGrade, + maxDownhillGrade, + minUphillGrade, + minDownhillGrade, + } = routeFields; // 「구조물 배치」 — 구 비정규 측점 섹션을 흡수한 단일 섹션(2026-08-17 컨테이너 병합). // 타입 목록과 옵션 칸은 서버 레지스트리에서 받아 그리고, 계곡 통과 시설(A군)은 관 @@ -517,74 +121,6 @@ export function createRoutePanel(callbacks: PanelCallbacks) { onReveal: () => callbacks.onStructureReveal?.(), }); - /** 지금 적용되는 종단기울기 상한(%) — 사용자가 값을 넣었으면 그 값, 비웠으면 - * 등급·지형의 법정 기준. 종단 그래프의 위반 판정이 이 값을 즉시 따라온다 - * (2026-08-19 사용자 지시 6). 특수지형+포장 예외(18%)는 서버가 최종 판정한다. */ - function currentSpeed(): RoutePanelValues["designSpeed"] { - const value = Number(designSpeed.value); - return (value === 40 || value === 30 ? value : 20) as RoutePanelValues["designSpeed"]; - } - - function gradeLimitPct(): number { - const criteria = PROFILE_CRITERIA[currentSpeed()]; - const terrain = terrainType.value as RoutePanelValues["terrainType"]; - const typed = parseOptional(maxGradePct); - return typed != null && typed > 0 ? typed : criteria.grade[terrain]; - } - - /** 임도 종류에 맞춰 설계속도 선택지를 갈아 끼운다 — 작업임도는 20만 쓸 수 있다. */ - function syncSpeedChoices(): void { - const allowed = SPEED_CHOICES[gradeClass.value] ?? [20]; - const keep = allowed.includes(currentSpeed()) ? currentSpeed() : allowed[0]; - designSpeed.replaceChildren( - ...allowed.map((value) => new Option(`${value} km/h`, String(value))), - ); - designSpeed.value = String(keep); - // 고를 것이 하나뿐이면 잠근다 — 열어 두면 바꿀 수 있다는 오해를 준다. - designSpeed.disabled = allowed.length < 2; - } - - /** 설계속도·지형 선택에 맞춰 법정 기준값을 placeholder와 안내문에 반영한다. */ - function syncCriteria(): void { - const speed = currentSpeed(); - const criteria = PROFILE_CRITERIA[speed]; - const terrain = terrainType.value as RoutePanelValues["terrainType"]; - maxGradePct.placeholder = String(criteria.grade[terrain]); - minVerticalRadius.placeholder = String(criteria.radius); - minTangentLength.placeholder = "20"; - const roadType = ROAD_TYPE_LABELS[gradeClass.value] ?? gradeClass.value; - criteriaNote.textContent = - `${roadType} · 설계속도 ${speed}km/h · ${terrain === "special" ? "특수지형" : "일반지형"} 기준 — ` + - `종단기울기 ${criteria.grade[terrain]}% 이하, 종단곡선 반경 ${criteria.radius}m 이상. ` + - "비워두면 이 기준이 적용됩니다."; - } - // 설계속도·지형뿐 아니라 기울기 세부 입력까지 — 바꾸는 즉시 안내문과 위반 기준이 - // 따라온다(2026-08-19 사용자 지시 6). onInputChange가 그래프 쪽 갱신을 잇는다. - [designSpeed, terrainType, maxGradePct].forEach((input) => - input.addEventListener("change", syncCriteria), - ); - maxGradePct.addEventListener("input", syncCriteria); - syncSpeedChoices(); - syncCriteria(); - - // 하단 고정 액션 행: [초기화][임시저장][횡단 이동] — 경로 확정 개념 폐지, 종·횡 통합 - // 확정은 B06에서 한다(2026-08-08 워크플로우 재정의). - const resetButton = button(L("Common_Btn_Reset"), callbacks.onReset, "danger"); - const tempSaveButton = button(L("B05_Route_Btn_TempSave"), callbacks.onTempSave); - const goCrossButton = button(L("B05_Route_Btn_GoCross"), callbacks.onGoCross, "filled"); - const actionRow = document.createElement("div"); - actionRow.className = "b05-route__actions"; - actionRow.append(resetButton, tempSaveButton, goCrossButton); - - // 사이드 최하단 고정 영역(2026-08-05 사용자 지시) — 위 구분선(dock 상단 테두리) → - // 구조물 목록 → 중간 구분선 → 액션 버튼 순. 목록을 「구조물 배치」 본문에 두면 - // 고른 구조물의 폼 길이에 밀려 화면 밖으로 나갔다(2026-08-18 사용자 지시). - const dockDivider = document.createElement("hr"); - dockDivider.className = "b05-structure__divider"; - const actionDock = document.createElement("div"); - actionDock.className = "b05-route__dock ui-sidebar-actions"; - actionDock.append(structures.listRoot, dockDivider, actionRow); - const inputElements = [ algorithm, gradeClass, @@ -608,40 +144,38 @@ export function createRoutePanel(callbacks: PanelCallbacks) { ]; inputElements.forEach((input) => input.addEventListener("change", callbacks.onInputChange)); - // 경로 계산 설정 — 사용여부 미결정으로 조작 불가(숨기지 않는다, 2026-08-18 사용자 - // 지시). inert가 본문 전체 입력·드래그를 막고, is-disabled가 흐림을 그린다. - // 제목 클릭(접기/펼치기)은 본문 밖이라 그대로 산다. - routeCalc.root.classList.add("is-disabled"); - routeCalc.body.inert = true; - - // 배치 순서: 구조물 배치 > 페이지 설정 > 경로 계산 설정(비활성) > 선택 포인트 - // (평소 숨김) > 하단 고정 dock (2026-08-18 사용자 확정). - root.append( - structures.root, - sectionOptions.root, - plannedRoute.root, - routeCalc.root, - selected.root, - actionDock, + // 좌측 패널 = 등록부가 칸을 차례대로 세우고 아래 고정 영역(단추 넷)을 붙인다. 하단 단추의 + // [확정]은 저장 흐름(`design_save`)이 이어질 때까지 꺼 둔다 — 종·횡 통합 확정은 횡단 보기에서 + // 한다(2026-08-08 워크플로우 재정의). + const design = createDesignPanel( + { + projectId: null, + parts: { structures, settings, route }, + dock: { + reset: { run: callbacks.onReset }, + save: { run: callbacks.onTempSave }, + confirm: { + run: () => undefined, + enabled: false, + title: "확정은 횡단 보기에서 합니다(저장 한 줄기가 이어지면 이 자리에서 켜집니다).", + }, + switchView: { run: callbacks.onGoCross }, + }, + }, + "profile", ); - // 컨테이너 제목 행 전체 클릭 시 본문을 접거나 편다(공용 collapsible). 내부 details 등 별도 - // 접힘 항목은 손대지 않는다. - attachCollapsible(root); - return { - root, + root: design.root, viewControls, /** 「구조물 배치」 섹션 API(타입 주입·목록 교체·계곡 시설 병합·선택·추가/이동/삭제). */ structures: structures as StructuresSection, /** 이어 공사 시작 기준(측점번호·누가거리 오프셋) 현재값. */ - stationDisplayOffset, + stationDisplayOffset: settings.stationDisplayOffset, /** 지금 적용되는 종단기울기 상한(%) — 그래프 위반 판정 즉시 반영용(2026-08-19). */ - gradeLimitPct, + gradeLimitPct: settings.gradeLimitPct, /** [3D 업데이트] 대기 표시 — 편집이 3D에 아직 안 들어갔음을 버튼에 알린다. */ - setCorridorPending(pending: boolean) { - corridorRefresh.classList.toggle("is-pending", pending); - }, + setCorridorPending, values(): RoutePanelValues { return { contourInterval: Number(contourInterval.value) || 1, @@ -657,7 +191,7 @@ export function createRoutePanel(callbacks: PanelCallbacks) { stationInterval: STATION_INTERVAL_M, crossSampleInterval: parseOptional(crossSampleInterval), longSampleInterval: parseOptional(longSampleInterval), - designSpeed: currentSpeed(), + designSpeed: settings.currentSpeed(), terrainType: terrainType.value as RoutePanelValues["terrainType"], maxGradePct: parseOptional(maxGradePct), minVerticalRadius: parseOptional(minVerticalRadius), @@ -685,7 +219,7 @@ export function createRoutePanel(callbacks: PanelCallbacks) { longSampleInterval.value = String(values.longSampleInterval); if (values.gradeClass) { gradeClass.value = values.gradeClass; - syncSpeedChoices(); + settings.syncSpeedChoices(); } if (values.designSpeed) designSpeed.value = String(values.designSpeed); if (values.terrainType) terrainType.value = values.terrainType; @@ -699,14 +233,10 @@ export function createRoutePanel(callbacks: PanelCallbacks) { endElevationOffset.value = String(values.endElevationOffset); if (values.enforcePipeClearance != null) enforcePipeClearance.checked = values.enforcePipeClearance; - syncCriteria(); + settings.syncCriteria(); }, setSelected(point: PlacedRoutePoint | null) { - selected.root.hidden = !point; - if (!point) return; - selectedName.textContent = `${point.type.toUpperCase()} (${point.x.toFixed(2)}, ${point.y.toFixed(2)})`; - radius.wrapper.hidden = point.type !== "ap" && point.type !== "fp"; - radius.value = String(point.radius_m ?? 25); + route.setSelected(point); }, }; } diff --git a/B05_Profile/B05_Profile_UI_Panel_Fields.ts b/B05_Profile/B05_Profile_UI_Panel_Fields.ts new file mode 100644 index 00000000..85c61537 --- /dev/null +++ b/B05_Profile/B05_Profile_UI_Panel_Fields.ts @@ -0,0 +1,149 @@ +/* ============================================================================= + * B05_Profile_UI_Panel_Fields.ts + * 좌측 패널이 함께 쓰는 **값 · 상수 · 입력 칸 만들기** — `B05_Profile_UI_Panel.ts` 가 714줄이라 + * 떼어낸 조각이다(계획서 24-2b). 본문 로직과 수치는 그대로다. + * ========================================================================== */ + +import { type ButtonVariant, createButton } from "@ui/ui_template_elements"; +import { currentLanguageIndex, ui_locales } from "@ui/ui_template_locale"; + +export interface RoutePanelValues { + contourInterval: number; + algorithm: "dijkstra" | "ridge_valley"; + /** 임도 종류 — 현행 3종(간선·산불진화·작업). branch(지선)는 폐지됐으나 기존 + * 저장분 호환을 위해 값으로는 받는다(2026-08-19). 값의 원천은 프로젝트 등록 + * 정보(projects.road_type)이며 화면에서는 고르지 않는다. */ + gradeClass: "trunk" | "fire" | "branch" | "work"; + /** 설계속도(km/h) — 임도는 속도를 낼 수 없는 노선이라 기본 20이다. 간선·산불진화만 + * 20~40에서 고를 수 있고 작업임도는 20 고정(별표2, 2026-08-19 사용자 확정). */ + designSpeed: 20 | 30 | 40; + paved: boolean; + minCurveRadius: number | null; + maxUphillGrade: number | null; + maxDownhillGrade: number | null; + minUphillGrade: number | null; + minDownhillGrade: number | null; + allowAvoidPassThrough: boolean; + stationInterval: number | null; + crossSampleInterval: number | null; + longSampleInterval: number | null; + terrainType: "normal" | "special"; + maxGradePct: number | null; + minVerticalRadius: number | null; + minTangentLength: number | null; + startElevationOffset: number | null; + endElevationOffset: number | null; + /** 횡단배수 최소 계획고를 계획선·편집에 강제할지 — 기본 해제(2026-09-01 사용자 지시). + * 해제해도 부족분 경고는 그대로 뜬다. 강제만 풀고 판단은 사용자에게 맡긴다. */ + enforcePipeClearance: boolean; +} + +/** + * 「임도설치 및 관리 등에 관한 규정」[별표 1-2] 기준값. + * 등급을 바꾸면 이 값이 계획선 폼의 placeholder(미입력 시 서버 기본값)로 반영된다. + * 실제 기본값 결정은 서버 config가 단일 소스이며 여기서는 안내만 한다. + */ +/** 측점 간격(m) — 20m 고정(2026-08-18 사용자 확정, 입력 UI 삭제). */ +export const STATION_INTERVAL_M = 20; + +/* 종단기울기·종단곡선 반경 — 지식DB `설계제원_총괄 §6·§7`(간선·산불진화 공통, + * 설계속도 40/30/20 → 7·10 / 8·12 / 9·14 %, 반경 450/250/100 m) 값과 일치한다 + * (2026-08-19 재점검). 서버 `legal_grade_limit_pct`가 같은 표를 쓰므로 화면 안내와 + * 실제 적용값이 어긋나지 않는다. + * + * ⚠ 미해결(사용자 협의 필요, 2026-08-19 재점검에서 확인): + * ① 등급 선택지가 간선·지선·작업인데 현행 규칙은 **지선 폐지**, 3종은 간선· + * 산불진화·작업이다(설계제원_총괄 §1 [구현] 지침). + * ② 지식DB의 40/30/20 표는 **설계속도**별이고 간선·산불진화 공통이다. 지금은 + * 등급에 속도를 고정 매핑(trunk=40·branch=30·work=20)했는데, 작업임도는 + * 설계속도 20 이하 + 종단기울기 최대 20% 범위 조정이라는 별도 규정이다. + * ③ "최소 곡선반경" 입력 칸의 안내값이 **종단**곡선 반경(450/250/100)이다. + * 평면 최소곡선반지름은 일반지형 60/30/15 (설계제원_총괄 §4). + * 값을 임의로 바꾸지 않고 남긴다 — 기본값 확정은 사용자 협의 영역이다. */ +export const PROFILE_CRITERIA: Record< + RoutePanelValues["designSpeed"], + { grade: Record; radius: number } +> = { + 40: { grade: { normal: 7, special: 10 }, radius: 450 }, + 30: { grade: { normal: 8, special: 12 }, radius: 250 }, + 20: { grade: { normal: 9, special: 14 }, radius: 100 }, +}; + +/** 임도 종류별 표시 이름 — 프로젝트 등록(B02)과 같은 말을 쓴다(2026-08-19 확정). */ +export const ROAD_TYPE_LABELS: Record = { + trunk: "간선임도", + fire: "산불진화임도", + work: "작업임도", + branch: "작업임도(구 지선)", +}; + +/** 종류별로 고를 수 있는 설계속도 — 작업임도는 20 이하라 20만 둔다(별표2 Ⅰ.3). */ +export const SPEED_CHOICES: Record> = { + trunk: [20, 30, 40], + fire: [20, 30, 40], + work: [20], + branch: [20], +}; + +export type WrappedInput = HTMLInputElement & { wrapper: HTMLLabelElement }; + +export function L(key: keyof typeof ui_locales): string { + return ui_locales[key][currentLanguageIndex]; +} + +export function button( + label: string, + onClick: () => void, + variant: ButtonVariant = "ghost", +): HTMLButtonElement { + return createButton({ label, variant, onClick: () => onClick() }); +} + +export function numberField(label: string, value = ""): WrappedInput { + const wrapper = document.createElement("label"); + wrapper.className = "b05-route__field"; + const caption = document.createElement("span"); + caption.textContent = label; + const input = document.createElement("input"); + input.type = "number"; + input.step = "0.01"; + input.value = value; + wrapper.append(caption, input); + return Object.assign(input, { wrapper }); +} + +export function checkbox(label: string, checked: boolean): WrappedInput { + const wrapper = document.createElement("label"); + wrapper.className = "b05-route__check"; + const input = document.createElement("input"); + input.type = "checkbox"; + input.checked = checked; + wrapper.append(input, document.createTextNode(label)); + return Object.assign(input, { wrapper }); +} + +export function toggleButton( + label: string, + checked: boolean, + onChange: (checked: boolean) => void, +): HTMLButtonElement { + const element = button( + label, + () => { + const active = !element.classList.contains("is-active"); + element.classList.toggle("is-active", active); + element.setAttribute("aria-pressed", String(active)); + onChange(active); + }, + "glass", + ); + element.classList.toggle("is-active", checked); + element.setAttribute("aria-pressed", String(checked)); + return element; +} + +export function parseOptional(input: HTMLInputElement): number | null { + if (!input.value.trim()) return null; + const value = Number(input.value); + return Number.isFinite(value) ? value : null; +} diff --git a/B05_Profile/B05_Profile_UI_Panel_Route.ts b/B05_Profile/B05_Profile_UI_Panel_Route.ts new file mode 100644 index 00000000..76ce7897 --- /dev/null +++ b/B05_Profile/B05_Profile_UI_Panel_Route.ts @@ -0,0 +1,131 @@ +/* ============================================================================= + * B05_Profile_UI_Panel_Route.ts + * 좌측 패널의 **노선 쪽 칸 재료** — 계획노선 편집 단추 · 경로 계산 설정(포인트 팔레트 · 알고리즘 · + * 조건) · 선택 포인트 상세. 칸 틀은 `B05_Profile_UI_Panel_Sections.ts` 가 씌운다(등록부). + * `B05_Profile_UI_Panel.ts` 에서 떼어냈다(계획서 24-2b). 본문 로직과 수치는 그대로다. + * ========================================================================== */ + +import { createSelectField } from "@ui/ui_template_elements"; +import type { PlacedRoutePoint, RoutePointKind } from "./B05_Profile_UI_Markers"; +import { button, checkbox, numberField } from "./B05_Profile_UI_Panel_Fields"; + +export interface RoutePartCallbacks { + onSolve: () => void; + /** [계획노선 편집] — 큰 모달을 열어 노선을 고친다(계산은 모달 [확인]에서만 돈다). */ + onEditPlannedRoute: () => void; + onMovePoint: () => void; + onDeletePoint: () => void; + onRadiusChange: (radius: number) => void; +} + +export function createRoutePart(callbacks: RoutePartCallbacks) { + // 계획노선 편집 — 노선을 고치는 유일한 입구(2026-09-06 사용자 확정). 자동탐색 + // (「경로 계산 설정」)은 사용 중단이라 이 자리가 노선을 바꾸는 길이다. + const plannedRouteBtn = document.createElement("button"); + plannedRouteBtn.type = "button"; + plannedRouteBtn.className = "b05-route__btn"; + plannedRouteBtn.textContent = "계획노선 편집"; + plannedRouteBtn.title = "예상노선 위에서 계획노선을 고칩니다. [확인] 때만 다시 계산합니다."; + plannedRouteBtn.addEventListener("click", () => callbacks.onEditPlannedRoute()); + + const paletteGrid = document.createElement("div"); + paletteGrid.className = "b05-route__palette"; + const pointLabels: Record = { + bp: "BP 시작점", + ep: "EP 종료점", + cp: "CP 경유점", + ap: "AP 회피구역", + fp: "FP 금지구역", + }; + (Object.keys(pointLabels) as RoutePointKind[]).forEach((kind) => { + const chip = document.createElement("div"); + chip.className = `b05-route__chip is-${kind}`; + chip.draggable = true; + chip.textContent = pointLabels[kind]; + chip.addEventListener("dragstart", (event) => event.dataTransfer?.setData("pointType", kind)); + paletteGrid.append(chip); + }); + + // 드롭다운은 공통 컴포넌트(createSelectField) 재사용. `.select`로 받아 이하 로직 불변. + const algorithmField = createSelectField({ + label: "알고리즘", + options: [ + { value: "dijkstra", text: "Dijkstra" }, + { value: "ridge_valley", text: "능선·계곡" }, + ], + }); + const algorithm = algorithmField.select; + const minCurveRadius = numberField("최소 곡선반경 (m)"); + const maxUphillGrade = numberField("오르막 경사 상한 (%)"); + const maxDownhillGrade = numberField("내리막 경사 상한 (%)"); + const minUphillGrade = numberField("오르막 경사 하한 (%)"); + const minDownhillGrade = numberField("내리막 경사 하한 (%)"); + const paved = checkbox("포장 임도", false); + const avoidPass = checkbox("회피구역 통과 허용", false); + const details = document.createElement("details"); + const summary = document.createElement("summary"); + summary.textContent = "사용한 조건"; + details.append( + summary, + minCurveRadius.wrapper, + maxUphillGrade.wrapper, + maxDownhillGrade.wrapper, + minUphillGrade.wrapper, + minDownhillGrade.wrapper, + ); + // [최적 경로 계산] 입구는 2026-09-06 사용자 확정으로 화면에서 내렸다 — 평면만 보고 + // 노선을 정하는 방식이라 실제 판단(평면·종단·횡단·유토곡선을 함께 봄)과 맞지 않는다. + // 코드·API 는 그대로 두고 버튼만 뺀다. 노선 변경은 계획노선 편집이 대신한다. + const solveButton = button("최적 경로 계산", callbacks.onSolve, "filled"); + solveButton.hidden = true; + + const selectedName = document.createElement("strong"); + const radius = numberField("회피/금지 반경 (m)", "25"); + radius.addEventListener("change", () => callbacks.onRadiusChange(Number(radius.value) || 1)); + const selectedActions = document.createElement("div"); + selectedActions.className = "b05-route__actions"; + selectedActions.append( + button("위치 이동", callbacks.onMovePoint), + button("삭제", callbacks.onDeletePoint, "danger"), + ); + // 칸 뿌리는 등록부가 만든 뒤 알려 준다 — 평소 숨김(포인트를 고를 때만 보인다). + let selectedRoot: HTMLElement | null = null; + + return { + plannedRouteButton: plannedRouteBtn, + /** 「경로 계산 설정」 본문 — 화면 순서대로. */ + calcNodes: [ + paletteGrid, + algorithmField.root, + paved.wrapper, + avoidPass.wrapper, + details, + solveButton, + ] as HTMLElement[], + /** 「선택 포인트 상세 설정」 본문 — 화면 순서대로. */ + selectedNodes: [selectedName, radius.wrapper, selectedActions] as HTMLElement[], + fields: { + algorithm, + paved, + avoidPass, + minCurveRadius, + maxUphillGrade, + maxDownhillGrade, + minUphillGrade, + minDownhillGrade, + }, + bindSelectedRoot(root: HTMLElement): void { + selectedRoot = root; + root.hidden = true; + }, + setSelected(point: PlacedRoutePoint | null): void { + if (selectedRoot) selectedRoot.hidden = !point; + if (!point) return; + selectedName.textContent = `${point.type.toUpperCase()} (${point.x.toFixed(2)}, ${point.y.toFixed(2)})`; + radius.wrapper.hidden = point.type !== "ap" && point.type !== "fp"; + radius.value = String(point.radius_m ?? 25); + }, + }; +} + +export type RoutePart = ReturnType; diff --git a/B05_Profile/B05_Profile_UI_Panel_Sections.ts b/B05_Profile/B05_Profile_UI_Panel_Sections.ts new file mode 100644 index 00000000..aa5437ee --- /dev/null +++ b/B05_Profile/B05_Profile_UI_Panel_Sections.ts @@ -0,0 +1,69 @@ +/* ============================================================================= + * B05_Profile_UI_Panel_Sections.ts + * 종단 보기 좌측 패널의 **칸 정의**(등록부 `registerPanelSection`) — 페이지 설정 · 계획노선 · + * 경로 계산 설정 · 선택 포인트 상세. 「구조물 배치」는 두 보기 공용이라 `design_sections.ts` 에 있다. + * + * 칸 차례(2026-08-18 사용자 확정): 구조물 배치 > 페이지 설정 > 계획노선 > 경로 계산 설정(비활성) + * > 선택 포인트(평소 숨김) > 아래 고정 영역. 재료는 `_Panel_Settings` · `_Panel_Route` 가 만들어 + * `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({ + id: "page-settings", + title: "페이지 설정", + views: ["profile"], + order: 20, + build: (ctx, view) => { + const frame = createSectionFrame(view, "페이지 설정", true); + frame.body.append(...part(ctx, "settings").nodes); + return frame.root; + }, +}); + +registerPanelSection({ + id: "planned-route", + title: "계획노선", + views: ["profile"], + order: 30, + build: (ctx, view) => { + const frame = createSectionFrame(view, "계획노선"); + frame.body.append(part(ctx, "route").plannedRouteButton); + return frame.root; + }, +}); + +registerPanelSection({ + id: "route-calc", + title: "경로 계산 설정", + views: ["profile"], + order: 40, + build: (ctx, view) => { + const frame = createSectionFrame(view, "경로 계산 설정", true); + frame.body.append(...part(ctx, "route").calcNodes); + // 경로 계산 설정 — 사용여부 미결정으로 조작 불가(숨기지 않는다, 2026-08-18 사용자 + // 지시). inert가 본문 전체 입력·드래그를 막고, is-disabled가 흐림을 그린다. + // 제목 클릭(접기/펼치기)은 본문 밖이라 그대로 산다. + frame.root.classList.add("is-disabled"); + frame.body.inert = true; + return frame.root; + }, +}); + +registerPanelSection({ + id: "selected-point", + title: "선택 포인트 상세 설정", + views: ["profile"], + order: 50, + build: (ctx, view) => { + const frame = createSectionFrame(view, "선택 포인트 상세 설정"); + const route = part(ctx, "route"); + frame.body.append(...route.selectedNodes); + route.bindSelectedRoot(frame.root); + return frame.root; + }, +}); diff --git a/B05_Profile/B05_Profile_UI_Panel_Settings.ts b/B05_Profile/B05_Profile_UI_Panel_Settings.ts new file mode 100644 index 00000000..eb61e285 --- /dev/null +++ b/B05_Profile/B05_Profile_UI_Panel_Settings.ts @@ -0,0 +1,234 @@ +/* ============================================================================= + * B05_Profile_UI_Panel_Settings.ts + * 좌측 패널 「페이지 설정」 칸의 **재료** — 시작 측점 · 샘플 간격 · 등고선 간격 · 임도 종류 · 설계속도 · + * 지형 구분 · 기준값. 칸 틀은 `B05_Profile_UI_Panel_Sections.ts` 가 씌운다(등록부). + * `B05_Profile_UI_Panel.ts` 에서 떼어냈다(계획서 24-2b). 본문 로직과 수치는 그대로다. + * + * 「페이지 설정」 — 등고선 간격·시작 측점 및 샘플링·종단 설계 기준 세 섹션 병합 + * (2026-08-18 사용자 확정). 측점 간격 입력은 삭제 — 20m 고정. + * ========================================================================== */ + +import { createSelectField } from "@ui/ui_template_elements"; +import { + L, + PROFILE_CRITERIA, + ROAD_TYPE_LABELS, + SPEED_CHOICES, + button, + checkbox, + numberField, + parseOptional, + type RoutePanelValues, +} from "./B05_Profile_UI_Panel_Fields"; + +export interface SettingsPartCallbacks { + onContourApply: (interval: number) => void | Promise; + /** 이어 공사 시작 기준(시작 측점·누가거리 시작)이 바뀔 때. */ + onStationDisplayChange: (offset: { station: number; cumulative: number }) => void; +} + +export function createSettingsPart(callbacks: SettingsPartCallbacks) { + // 등고선 간격 행 — 「페이지 설정」 섹션에 들어간다(2026-08-18 컨테이너 병합). + const contourInterval = numberField("간격 (m), 최소 0.5m", "1"); + const contourRow = document.createElement("div"); + contourRow.className = "b05-route__contour-row"; + // 등고선 재적용은 화면 전체를 잠그지 않는다(CLAUDE.md 5장) — 누른 버튼만 잠가 + // 진행을 알리고, 나머지 조작은 그대로 열어 둔다. + const contourApply = button("재적용", () => { + contourApply.disabled = true; + contourApply.textContent = "적용 중…"; + void Promise.resolve(callbacks.onContourApply(Number(contourInterval.value) || 1)).finally( + () => { + contourApply.disabled = false; + contourApply.textContent = "재적용"; + }, + ); + }); + contourRow.append(contourInterval.wrapper, contourApply); + + // 임도 종류는 **프로젝트 등록(B02)에서 정한 값**을 그대로 쓴다 — 여기서 고치는 + // 값이 아니다(2026-08-19 사용자 확정). 화면에는 읽기 전용으로 보여만 준다. + const gradeField = createSelectField({ + label: "임도 종류 (프로젝트 등록값)", + options: [ + { value: "trunk", text: ROAD_TYPE_LABELS.trunk }, + { value: "fire", text: ROAD_TYPE_LABELS.fire }, + { value: "work", text: ROAD_TYPE_LABELS.work }, + { value: "branch", text: ROAD_TYPE_LABELS.branch }, + ], + }); + const gradeClass = gradeField.select; + gradeClass.disabled = true; + gradeClass.title = "프로젝트 등록 정보의 임도 종류입니다. 변경은 대시보드에서 하세요."; + // 설계속도 — 임도는 속도를 낼 수 없어 기본 20km/h다. 종류에 따라 선택지가 갈린다. + const speedField = createSelectField({ + label: "설계속도 (km/h)", + options: [20, 30, 40].map((value) => ({ value: String(value), text: `${value} km/h` })), + }); + const designSpeed = speedField.select; + designSpeed.value = "20"; + designSpeed.title = + "종단기울기·종단곡선의 법정 기준은 설계속도로 정해집니다(별표2).\n" + + "간선·산불진화임도는 20~40, 작업임도는 20만 쓸 수 있습니다."; + + const crossSampleInterval = numberField(L("B05_Route_Field_CrossSample")); + const longSampleInterval = numberField(L("B05_Route_Field_LongSample")); + // 샘플 간격 두 칸은 2열 한 행 — 종단이 먼저(2026-08-18 사용자 지시). + const sampleRow = document.createElement("div"); + sampleRow.className = "b05-route__field-row"; + sampleRow.append(longSampleInterval.wrapper, crossSampleInterval.wrapper); + const terrainField = createSelectField({ + label: "지형 구분", + options: [ + { value: "normal", text: "일반지형" }, + { value: "special", text: "특수지형" }, + ], + }); + const terrainType = terrainField.select; + // 기본값 특수지형(2026-09-02 사용자 지시) — 임도 대상지는 대개 특수지형이라 + // 매번 바꿔야 했다. 저장분이 있으면 `apply()`가 그 값으로 덮는다. + terrainType.value = "special"; + // 역기울기(5%) 상한 방향은 서버가 지반 형상에서 자동 판정(main_direction="auto")하므로 + // 수동 선택 UI는 두지 않는다. 노선 균형 구역 길이도 자동 산출 기본값(전체 1구역)에 맡긴다. + // 횡단배수 최소고 강제(2026-09-01 사용자 지시) — 기본 해제. 켜면 자동 계획선이 배관 + // 자리를 시설 여유만큼 들어 올리고, 그 아래로 내리는 편집도 막힌다. 꺼도 부족분 경고는 + // 종단 상단 표시줄에 계속 뜬다. + const enforcePipeClearance = checkbox("횡단배수 최소고 강제", false); + enforcePipeClearance.title = + "켜면 계획선이 배수관·BOX암거·세월교 자리에서 관경(구체 높이)+토피 0.5m 만큼 들립니다.\n" + + "끄면 들어 올리지 않고 부족분은 경고로만 알립니다(기본)."; + const maxGradePct = numberField("최대 종단기울기 (%)"); + const minVerticalRadius = numberField("종단곡선 최소 반경 (m)"); + const minTangentLength = numberField("최소 직선 길이 (m)"); + const startElevationOffset = numberField("시점 계획고 조정 (m)"); + const endElevationOffset = numberField("종점 계획고 조정 (m)"); + const criteriaNote = document.createElement("p"); + criteriaNote.className = "b05-route__note"; + const gradeAdvanced = document.createElement("details"); + const gradeSummary = document.createElement("summary"); + gradeSummary.textContent = "기준값 직접 지정"; + gradeAdvanced.append( + gradeSummary, + maxGradePct.wrapper, + minVerticalRadius.wrapper, + minTangentLength.wrapper, + startElevationOffset.wrapper, + endElevationOffset.wrapper, + ); + // 병합 구획 구분선 — 샘플링 / 등고선 / 종단 설계 기준. + const settingsDivider1 = document.createElement("hr"); + settingsDivider1.className = "b05-structure__divider"; + const settingsDivider2 = settingsDivider1.cloneNode() as HTMLHRElement; + + // 공사 시작 기준 — 이전 공사에 이어 시공할 때 0측점을 임의 측점/누가거리로 시작 표기한다. + // (내부 chainage는 0기준 유지, 측점 라벨·누가거리 "표시"만 이 값만큼 이동.) 기본값 0/0. + // 별도 "공사 시작점" 컨테이너를 없애고 "시작 측점 및 샘플링 설정" 최상단으로 일원화 + // (2026-08-06 사용자 지시). + const startStation = numberField("시작 측점", "0"); + startStation.step = "1"; + startStation.min = "0"; + const startCumulative = numberField("시작 누가거리 (m)", "0"); + // 2열×2행: 1행 항목명 · 2행 값 입력 / 1열 시작 측점 · 2열 시작 누가거리. + const startRow = document.createElement("div"); + startRow.className = "b05-route__field-row"; + startRow.append(startStation.wrapper, startCumulative.wrapper); + const stationDisplayOffset = (): { station: number; cumulative: number } => ({ + station: Math.max(0, Math.round(Number(startStation.value) || 0)), + cumulative: Number(startCumulative.value) || 0, + }); + [startStation, startCumulative].forEach((input) => + input.addEventListener("change", () => + callbacks.onStationDisplayChange(stationDisplayOffset()), + ), + ); + + /** 지금 적용되는 종단기울기 상한(%) — 사용자가 값을 넣었으면 그 값, 비웠으면 + * 등급·지형의 법정 기준. 종단 그래프의 위반 판정이 이 값을 즉시 따라온다 + * (2026-08-19 사용자 지시 6). 특수지형+포장 예외(18%)는 서버가 최종 판정한다. */ + function currentSpeed(): RoutePanelValues["designSpeed"] { + const value = Number(designSpeed.value); + return (value === 40 || value === 30 ? value : 20) as RoutePanelValues["designSpeed"]; + } + + function gradeLimitPct(): number { + const criteria = PROFILE_CRITERIA[currentSpeed()]; + const terrain = terrainType.value as RoutePanelValues["terrainType"]; + const typed = parseOptional(maxGradePct); + return typed != null && typed > 0 ? typed : criteria.grade[terrain]; + } + + /** 임도 종류에 맞춰 설계속도 선택지를 갈아 끼운다 — 작업임도는 20만 쓸 수 있다. */ + function syncSpeedChoices(): void { + const allowed = SPEED_CHOICES[gradeClass.value] ?? [20]; + const keep = allowed.includes(currentSpeed()) ? currentSpeed() : allowed[0]; + designSpeed.replaceChildren( + ...allowed.map((value) => new Option(`${value} km/h`, String(value))), + ); + designSpeed.value = String(keep); + // 고를 것이 하나뿐이면 잠근다 — 열어 두면 바꿀 수 있다는 오해를 준다. + designSpeed.disabled = allowed.length < 2; + } + + /** 설계속도·지형 선택에 맞춰 법정 기준값을 placeholder와 안내문에 반영한다. */ + function syncCriteria(): void { + const speed = currentSpeed(); + const criteria = PROFILE_CRITERIA[speed]; + const terrain = terrainType.value as RoutePanelValues["terrainType"]; + maxGradePct.placeholder = String(criteria.grade[terrain]); + minVerticalRadius.placeholder = String(criteria.radius); + minTangentLength.placeholder = "20"; + const roadType = ROAD_TYPE_LABELS[gradeClass.value] ?? gradeClass.value; + criteriaNote.textContent = + `${roadType} · 설계속도 ${speed}km/h · ${terrain === "special" ? "특수지형" : "일반지형"} 기준 — ` + + `종단기울기 ${criteria.grade[terrain]}% 이하, 종단곡선 반경 ${criteria.radius}m 이상. ` + + "비워두면 이 기준이 적용됩니다."; + } + // 설계속도·지형뿐 아니라 기울기 세부 입력까지 — 바꾸는 즉시 안내문과 위반 기준이 + // 따라온다(2026-08-19 사용자 지시 6). onInputChange가 그래프 쪽 갱신을 잇는다. + [designSpeed, terrainType, maxGradePct].forEach((input) => + input.addEventListener("change", syncCriteria), + ); + maxGradePct.addEventListener("input", syncCriteria); + syncSpeedChoices(); + syncCriteria(); + + return { + /** 「페이지 설정」 본문 — 화면 순서대로. */ + nodes: [ + startRow, + sampleRow, + settingsDivider1, + contourRow, + settingsDivider2, + // 임도 종류(등록값) → 설계속도 → 지형 구분 순 — 법정 기준이 정해지는 순서다 + // (2026-08-19 사용자 확정: 종류는 B02에서, 속도·지형은 여기서). + gradeField.root, + speedField.root, + terrainField.root, + criteriaNote, + enforcePipeClearance.wrapper, + gradeAdvanced, + ] as HTMLElement[], + fields: { + contourInterval, + gradeClass, + designSpeed, + terrainType, + crossSampleInterval, + longSampleInterval, + maxGradePct, + minVerticalRadius, + minTangentLength, + startElevationOffset, + endElevationOffset, + enforcePipeClearance, + }, + stationDisplayOffset, + gradeLimitPct, + currentSpeed, + syncSpeedChoices, + syncCriteria, + }; +} + +export type SettingsPart = ReturnType; diff --git a/B05_Profile/B05_Profile_UI_Panel_View.ts b/B05_Profile/B05_Profile_UI_Panel_View.ts new file mode 100644 index 00000000..6b340ab0 --- /dev/null +++ b/B05_Profile/B05_Profile_UI_Panel_View.ts @@ -0,0 +1,101 @@ +/* ============================================================================= + * B05_Profile_UI_Panel_View.ts + * 3D 화면 위에 얹는 **보기 조작 줄**(ISO·TOP·… · 표시 켬끔 · 뷰 초기화 · [3D 업데이트]). + * 좌측 패널 칸이 아니라 뷰어에 붙는다 — `B05_Profile_UI_Panel.ts` 에서 떼어냈다(계획서 24-2b). + * ========================================================================== */ + +import { L, button, toggleButton } from "./B05_Profile_UI_Panel_Fields"; + +/** 뷰 조작 줄이 부르는 것 — 좌측 패널 콜백의 일부다. */ +export interface ViewControlCallbacks { + onSurfaceVisible: (visible: boolean) => void; + onCorridorVisible: (visible: boolean) => void; + onContoursVisible: (visible: boolean) => void; + onAxesVisible: (visible: boolean) => void; + onStationLinesVisible: (visible: boolean) => void; + onStationLabelsVisible: (visible: boolean) => void; + onSurfaceGrayscale: (grayscale: boolean) => void; + onView: (view: "iso" | "top" | "front" | "side") => void; + onProjection: (kind: "perspective" | "ortho") => void; + onResetView: () => void; + onCorridorRefresh: () => void | Promise; +} + +export function createViewControls(callbacks: ViewControlCallbacks): { + viewControls: HTMLElement; + /** [3D 업데이트] 대기 표시 — 편집이 3D에 아직 안 들어갔음을 버튼에 알린다. */ + setCorridorPending: (pending: boolean) => void; +} { + const viewControls = document.createElement("div"); + viewControls.className = "b05-route__view-controls"; + const viewButtons = document.createElement("div"); + viewButtons.className = "b05-route__view-group"; + (["iso", "top", "front", "side"] as const).forEach((preset) => + viewButtons.append(button(preset.toUpperCase(), () => callbacks.onView(preset), "glass")), + ); + // 직교/원근 전환(2026-09-04 사용자 지시) — 기본은 원근이고, 단추 글자는 **바뀔 쪽**을 + // 가리킨다(누르면 그쪽으로 간다). + let projection: "perspective" | "ortho" = "perspective"; + const projectionButton = button( + "직교로", + () => { + projection = projection === "perspective" ? "ortho" : "perspective"; + projectionButton.textContent = projection === "perspective" ? "직교로" : "원근으로"; + callbacks.onProjection(projection); + }, + "glass", + ); + viewButtons.append(projectionButton); + const visibilityButtons = document.createElement("div"); + visibilityButtons.className = "b05-route__view-group"; + visibilityButtons.append( + toggleButton("지표면", true, callbacks.onSurfaceVisible), + // 예상형상(2026-08-23): ON=공사 후 형상(기본), OFF=원지반 완전체. + toggleButton("예상형상", true, callbacks.onCorridorVisible), + toggleButton("등고선", true, callbacks.onContoursVisible), + toggleButton("축 표시", false, callbacks.onAxesVisible), + toggleButton(L("B05_Route_Field_StationLines"), true, callbacks.onStationLinesVisible), + toggleButton(L("B05_Route_Field_StationLabels"), true, callbacks.onStationLabelsVisible), + // 무지개 고도색이 헷갈릴 때 명도만 남기는 흑백 표시(2026-08-05 사용자 요청). + // 기본 켜짐(2026-08-23 사용자 확정) — 예상형상 색이 지형 고도색에 묻히지 않는다. + toggleButton("흑백 지형", true, callbacks.onSurfaceGrayscale), + ); + // [3D 업데이트](2026-09-01 사용자 지시) — 계획선을 고칠 때마다 3D가 따라오면 조작이 + // 무거워, 갱신을 이 버튼으로 모았다. 편집이 쌓이면 is-pending으로 알리고, 누르는 동안은 + // 이 버튼만 잠근다(화면 전체 잠금 금지 — CLAUDE.md 5장). + const corridorRefresh = button( + "3D 업데이트", + () => { + corridorRefresh.disabled = true; + corridorRefresh.textContent = "갱신 중…"; + void Promise.resolve(callbacks.onCorridorRefresh()).finally(() => { + corridorRefresh.disabled = false; + corridorRefresh.textContent = "3D 업데이트"; + corridorRefresh.classList.remove("is-pending"); + }); + }, + "glass", + ); + corridorRefresh.title = + "계획선 편집을 3D 예상형상·측점선에 반영합니다.\n편집이 쌓이면 버튼에 표시가 붙습니다."; + const separator1 = document.createElement("span"); + separator1.className = "b05-route__view-separator"; + const separator2 = separator1.cloneNode() as HTMLSpanElement; + const separator3 = separator1.cloneNode() as HTMLSpanElement; + viewControls.append( + viewButtons, + separator1, + visibilityButtons, + separator2, + button("뷰 초기화", callbacks.onResetView, "glass"), + separator3, + corridorRefresh, + ); + + return { + viewControls, + setCorridorPending(pending: boolean) { + corridorRefresh.classList.toggle("is-pending", pending); + }, + }; +} diff --git a/B06_Section/B06_Section_UI_Page.ts b/B06_Section/B06_Section_UI_Page.ts index b4f5f165..55945fab 100644 --- a/B06_Section/B06_Section_UI_Page.ts +++ b/B06_Section/B06_Section_UI_Page.ts @@ -4,11 +4,10 @@ import { CURRENT_PROJECT_ID_KEY, ROUTES } from "@config/config_frontend"; import { leaveForDashboard } from "../A00_Common/b_missing_data_guard"; import { readByKey, stateKey, writeByKey } from "../A00_Common/b_page_state"; import { navigateTo } from "../A00_Common/router"; -import { createButton, createInputField, showToast } from "@ui/ui_template_elements"; +import { showToast } from "@ui/ui_template_elements"; import { mountMissingStationNotice } from "./B06_Section_UI_Missing_Stations"; import type { StructureInstance, StructureType } from "../B05_Profile/B05_Profile_Api_Structures"; import { createWorkflowLayout } from "@ui/ui_template_workflow_layout"; -import { attachCollapsible } from "@ui/ui_template_collapsible"; import { workflowSteps } from "../A00_Common/b_page_scaffold"; import { fetchWorkflowState, @@ -60,7 +59,10 @@ import "./B06_Section_UI_Style_Cross_Controls.css"; import "./B06_Section_UI_Style_Cross_Areas.css"; import { loadSectionDetail } from "./B06_Section_Section_Store"; import { applyStructureAreaRows, structureAreaRows } from "./B06_Section_Structure_Layouts"; -import { buildGroup, createSampleWidener, L } from "./B06_Section_UI_Page_Common"; +import { createSampleWidener, L } from "./B06_Section_UI_Page_Common"; +import { createCrossViewPart, createStandardSlot } from "./B06_Section_UI_Page_Panel"; +import { createDesignPanel } from "../A00_Common/design/design_panel"; +import { resetDesign } from "../A00_Common/design/design_reset"; import "@util/common_util_mass_haul.css"; export async function renderB06ProfileCross(root: HTMLElement): Promise { @@ -70,65 +72,18 @@ export async function renderB06ProfileCross(root: HTMLElement): Promise { let stationInterval: number | undefined; // 표준 횡단면 설정 패널 자리. context 로드 후 config 기본값으로 채운다. - // 표준 횡단면 설정은 기본 접힘(2026-08-23 사용자 지시) — 자주 여는 칸이 아니다. - const standardGroup = buildGroup(L("B06_Std_Title"), true); - const standardPanelSlot = document.createElement("div"); - standardGroup.append(standardPanelSlot); + const standardPanelSlot = createStandardSlot(); let standardPanel: StandardPanelController | null = null; // 높이 배율 옵션은 폐지 — 항상 1배율(2026-08-06 사용자 지시). - // 횡단 반폭은 **자체 컨테이너 + 자체 [보기 반폭 적용] 버튼**이다(2026-08-23 사용자 지시) - // — 표준 횡단면 설정의 [전체 측점 반영]에 물려 있어 반폭만 바꿔도 전 측점 설계 - // 재계산을 기다려야 했다. 요청 반폭이 보유 샘플 폭(기본 20m, config - // SECTION_CROSS_HALF_WIDTH_M) 이하면 표시만 바꾸므로 즉시 끝나고, 넘을 때만 재생성한다. - const crossHalfWidthField = createInputField({ - label: L("B05_Route_Field_CrossHalfWidth"), - type: "number", - }); - crossHalfWidthField.input.min = "0.1"; - crossHalfWidthField.input.step = "0.1"; - const applyWidthButton = createButton({ - label: L("B06_View_Apply"), - variant: "filled", - onClick: () => void applyCrossHalfWidth(), - }); - applyWidthButton.title = L("B06_View_Apply_Tip"); - const viewGroup = buildGroup(L("B06_View_Title")); - // 반폭 입력 + [적용]은 한 줄(2026-08-23 사용자 지시). - const viewRow = document.createElement("div"); - viewRow.className = "b06-profile__field-row"; - viewRow.append(crossHalfWidthField.root, applyWidthButton); - viewGroup.append(viewRow); + // 「횡단 보기 설정」 칸 재료(반폭 입력 + [보기 반폭 적용])는 `_Page_Panel` 이 만든다. + const crossView = createCrossViewPart(() => void applyCrossHalfWidth()); + const crossHalfWidthField = crossView.field; /** 횡단면도 **표시** 반폭 기본값(m) — 2026-08-19 사용자 지시로 20 → 12. 계산·보유 * 샘플 폭은 백엔드 기본(SECTION_CROSS_HALF_WIDTH_M = 20m) 그대로라 12는 표시 * 범위만 좁힌다(12 ≤ 20이므로 재계산 없음). 계산 범위와 혼동 금지. */ const DISPLAY_HALF_WIDTH_DEFAULT_M = 12; - // 하단 액션 [종단 이동][임시저장][확정] — 종단 이동은 저장 없이 B05로 페이지 이동만, - // 확정은 임시저장과 같은 내용 저장 후 stage 2·3을 닫고 B07 상세설계로 넘어간다 - // (2026-08-08 워크플로우 재정의). - const goProfileButton = createButton({ - label: L("B06_Profile_Btn_GoProfile"), - variant: "ghost", - onClick: () => navigateTo(ROUTES.B05_PROFILE), - }); - const saveButton = createButton({ - label: L("B06_Profile_Btn_Save"), - variant: "ghost", - onClick: () => void saveCurrentSections(persistContext), - }); - saveButton.title = L("B06_Profile_Btn_Save_Tip"); - saveButton.disabled = true; - const confirmButton = createButton({ - label: L("B06_Profile_Btn_Confirm"), - variant: "filled", - onClick: () => void confirmCurrentSections(persistContext), - }); - confirmButton.disabled = true; - const actionRow = document.createElement("div"); - actionRow.className = "b06-profile__actions"; - actionRow.append(goProfileButton, saveButton, confirmButton); - /** 목록·폼에서 고른 시설의 측점 카드를 선택·스크롤 — 가장 가까운 카드로 간다. */ const focusStationAt = (chainageM: number): void => { const sections = sectionDetail?.cross_sections; @@ -202,18 +157,29 @@ export async function renderB06ProfileCross(root: HTMLElement): Promise { applyPipeOptionsToCache(pipeOptionsContext, chainageM, patch), }); - const dockDivider = document.createElement("hr"); - dockDivider.className = "b05-structure__divider"; - const actionDock = document.createElement("div"); - actionDock.className = "b05-route__dock ui-sidebar-actions"; - actionDock.append(structuresPanel.listRoot, dockDivider, actionRow); - - const leftForm = document.createElement("div"); - leftForm.className = "b06-profile__form"; - // 순서: 구조물 배치(최상단 — 2026-08-29 사용자 지시) → 횡단 보기 설정 → 표준. - leftForm.append(structuresPanel.root, viewGroup, standardGroup, actionDock); - // 그룹 제목 행 클릭 시 접기/펼치기(N-4-1). 액션 버튼 행은 collapsible 아님. - attachCollapsible(leftForm); + // 좌측 패널 = 등록부가 칸을 차례대로 세우고 아래 고정 영역(단추 넷)을 붙인다. 순서: 구조물 배치 + // (최상단 — 2026-08-29 사용자 지시) → 횡단 보기 설정 → 표준. 자료가 오기 전에는 [저장]·[확정]을 + // 잠근다(`updateActionState`). 확정은 임시저장과 같은 내용 저장 후 stage 2·3을 닫고 B07 상세설계로 + // 넘어간다(2026-08-08 워크플로우 재정의). + const design = createDesignPanel( + { + projectId, + parts: { + structures: structuresPanel, + crossView, + standardSlot: standardPanelSlot, + }, + dock: { + reset: { run: () => resetDesign(projectId ?? "", currentRouteId) }, + save: { run: () => saveCurrentSections(persistContext), enabled: false }, + confirm: { run: () => confirmCurrentSections(persistContext), enabled: false }, + switchView: { run: () => navigateTo(ROUTES.B05_PROFILE) }, + }, + }, + "cross", + ); + const leftForm = design.root; + const saveTip = L("B06_Profile_Btn_Save_Tip"); // 설계 선택 반영·재계산·소단 동기화는 따로 뗀 모듈이 맡는다(2026-09-13 분리). const { @@ -451,8 +417,11 @@ export async function renderB06ProfileCross(root: HTMLElement): Promise { } function updateActionState(): void { - saveButton.disabled = sectionDetail === null; - confirmButton.disabled = sectionDetail === null; + const ready = sectionDetail !== null; + design.dock.setActions({ + save: { run: () => saveCurrentSections(persistContext), enabled: ready, title: saveTip }, + confirm: { run: () => confirmCurrentSections(persistContext), enabled: ready }, + }); } /** 세션에 남은 선택을 이어받는다(2026-09-04 — 두 화면이 한 페이지처럼). diff --git a/B06_Section/B06_Section_UI_Page_Common.ts b/B06_Section/B06_Section_UI_Page_Common.ts index e8956f76..97e343e2 100644 --- a/B06_Section/B06_Section_UI_Page_Common.ts +++ b/B06_Section/B06_Section_UI_Page_Common.ts @@ -11,16 +11,6 @@ export function L(key: keyof typeof ui_locales): string { return ui_locales[key][currentLanguageIndex]; } -export function buildGroup(legend: string, collapsed = false): HTMLElement { - const group = document.createElement("section"); - group.className = `b06-profile__group ui-collapsible ui-sidebar-section${collapsed ? " is-collapsed" : ""}`; - const title = document.createElement("h3"); - title.className = "b06-profile__group-legend ui-collapsible__title"; - title.textContent = legend; - group.append(title); - return group; -} - /** 반폭 확대(백엔드 재생성)에 필요한 페이지 상태 — 클로저 대신 함수로 받아 결합을 끊는다. */ export interface SampleWidenerDeps { /** 현재 대상 경로. 없으면 확대 불가. */ diff --git a/B06_Section/B06_Section_UI_Page_Panel.ts b/B06_Section/B06_Section_UI_Page_Panel.ts new file mode 100644 index 00000000..ffbc9a70 --- /dev/null +++ b/B06_Section/B06_Section_UI_Page_Panel.ts @@ -0,0 +1,70 @@ +/* ============================================================================= + * B06_Section_UI_Page_Panel.ts + * 횡단 보기 좌측 패널의 **칸 정의**(등록부 `registerPanelSection`)와 그 재료 — 횡단 보기 설정 · + * 표준 횡단면 설정. 「구조물 배치」는 두 보기 공용이라 `design_sections.ts` 에 있다. + * + * 칸 차례(2026-08-29 사용자 지시): 구조물 배치(최상단) > 횡단 보기 설정 > 표준 횡단면 설정 > + * 아래 고정 영역. 페이지(`B06_Section_UI_Page.ts`)는 칸도 단추도 직접 만들지 않고 여기 재료와 + * `createDesignPanel` 만 쓴다(계획서 24-2b · 시험 `test_design_view_parity.py`). + * ========================================================================== */ + +import "../A00_Common/design/design_sections"; +import { createSectionFrame, part, registerPanelSection } from "../A00_Common/design/design_panel"; +import { createButton, createInputField } from "@ui/ui_template_elements"; +import { L } from "./B06_Section_UI_Page_Common"; + +/** 「횡단 보기 설정」 재료 — 반폭 입력 + 자체 [보기 반폭 적용] 한 줄. + * + * 횡단 반폭은 **자체 컨테이너 + 자체 [보기 반폭 적용] 버튼**이다(2026-08-23 사용자 지시) — 표준 + * 횡단면 설정의 [전체 측점 반영]에 물려 있어 반폭만 바꿔도 전 측점 설계 재계산을 기다려야 했다. */ +export function createCrossViewPart(onApply: () => void) { + const field = createInputField({ + label: L("B05_Route_Field_CrossHalfWidth"), + type: "number", + }); + field.input.min = "0.1"; + field.input.step = "0.1"; + const applyButton = createButton({ + label: L("B06_View_Apply"), + variant: "filled", + onClick: onApply, + }); + applyButton.title = L("B06_View_Apply_Tip"); + // 반폭 입력 + [적용]은 한 줄(2026-08-23 사용자 지시). + const row = document.createElement("div"); + row.className = "b06-profile__field-row"; + row.append(field.root, applyButton); + return { field, row }; +} + +export type CrossViewPart = ReturnType; + +/** 「표준 횡단면 설정」 패널이 들어갈 자리 — context 를 받은 뒤 패널을 채운다. */ +export function createStandardSlot(): HTMLElement { + return document.createElement("div"); +} + +registerPanelSection({ + id: "cross-view", + title: L("B06_View_Title"), + views: ["cross"], + order: 20, + build: (ctx, view) => { + const frame = createSectionFrame(view, L("B06_View_Title")); + frame.body.append(part(ctx, "crossView").row); + return frame.root; + }, +}); + +registerPanelSection({ + id: "standard-cross", + title: L("B06_Std_Title"), + views: ["cross"], + order: 30, + build: (ctx, view) => { + // 표준 횡단면 설정은 기본 접힘(2026-08-23 사용자 지시) — 자주 여는 칸이 아니다. + const frame = createSectionFrame(view, L("B06_Std_Title"), true); + frame.body.append(part(ctx, "standardSlot")); + return frame.root; + }, +});