refactor(B05·B06): 좌측 칸을 등록부로 · 아래 단추 넷 · B05 Panel 나눔(24-2b)

- B05 좌측 패널(714줄)을 Fields · Settings · Route · View · Sections 로 나누고 createRoutePanel 은 등록부로 세움
- B06 좌측 칸(횡단 보기 설정 · 표준 횡단면 설정)을 등록부로 · 페이지는 createDesignPanel 만 부름
- 「구조물 배치」는 design_sections 한 정의로 두 보기
- 아래 단추 [초기화][저장][확정][보기 전환] — B05 [확정]은 저장 흐름 이음 전까지 꺼 둠
- [초기화] 는 design_reset 한 함수를 두 보기가 부름
- 칸 DOM 은 고치기 전과 같음(전후 DOM 대조)

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01EauA2ATmqNRbeV48s9CcBr
This commit is contained in:
2026-09-29 20:17:35 +09:00
co-authored by Claude Sonnet 5.5
parent 439f605ba6
commit 2c520537b6
13 changed files with 970 additions and 669 deletions
+4 -2
View File
@@ -103,9 +103,11 @@ export function createDesignDock(
(Object.keys(buttons) as Array<keyof DesignDockActions>).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";
};
+59
View File
@@ -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<void> {
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();
}
}
+31
View File
@@ -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<StructuresPart>(ctx, "structures");
// 구조물 목록은 「구조물 배치」 본문이 아니라 아래 고정 영역에 놓는다 — 본문에 두면
// 고른 구조물의 폼 길이에 밀려 화면 밖으로 나갔다(2026-08-18 사용자 지시).
return { root: structures.root, dock: structures.listRoot };
},
});
+8 -42
View File
@@ -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<void> {
}
}
/** [초기화] — 사용자 편집 전부 폐기, 계획노선 CSV 기본값으로 B05·B06 재계산 후 재진입.
* 네이티브 confirm은 공용 헤드 브라우저가 자동 취소해 버튼이 죽은 듯 보였다
* (2026-08-19 진단) — 화면 안 모달로 확인받는다. */
/** [초기화] — 두 보기가 같은 함수를 부른다(`design_reset` · 계획서 24-2b). 페이지만 아는
* 상단측 변경분만 여기서 비운다. */
export async function resetDesignAction(ctx: PageActionContext): Promise<void> {
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(),
);
}
+77 -547
View File
@@ -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<RoutePanelValues["terrainType"], number>; 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<string, string> = {
trunk: "간선임도",
fire: "산불진화임도",
work: "작업임도",
branch: "작업임도(구 지선)",
};
/** 종류별로 고를 수 있는 설계속도 — 작업임도는 20 이하라 20만 둔다(별표2 Ⅰ.3). */
const SPEED_CHOICES: Record<string, Array<RoutePanelValues["designSpeed"]>> = {
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<RoutePointKind, string> = {
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);
},
};
}
+149
View File
@@ -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<RoutePanelValues["terrainType"], number>; 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<string, string> = {
trunk: "간선임도",
fire: "산불진화임도",
work: "작업임도",
branch: "작업임도(구 지선)",
};
/** 종류별로 고를 수 있는 설계속도 — 작업임도는 20 이하라 20만 둔다(별표2 Ⅰ.3). */
export const SPEED_CHOICES: Record<string, Array<RoutePanelValues["designSpeed"]>> = {
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;
}
+131
View File
@@ -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<RoutePointKind, string> = {
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<typeof createRoutePart>;
@@ -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<SettingsPart>(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<RoutePart>(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<RoutePart>(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<RoutePart>(ctx, "route");
frame.body.append(...route.selectedNodes);
route.bindSelectedRoot(frame.root);
return frame.root;
},
});
@@ -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<void>;
/** 이어 공사 시작 기준(시작 측점·누가거리 시작)이 바뀔 때. */
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<typeof createSettingsPart>;
+101
View File
@@ -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<void>;
}
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);
},
};
}
+37 -68
View File
@@ -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<void> {
@@ -70,65 +72,18 @@ export async function renderB06ProfileCross(root: HTMLElement): Promise<void> {
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<void> {
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<void> {
}
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 — 두 화면이 한 페이지처럼).
-10
View File
@@ -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 {
/** 현재 대상 경로. 없으면 확대 불가. */
+70
View File
@@ -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<typeof createCrossViewPart>;
/** 「표준 횡단면 설정」 패널이 들어갈 자리 — 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<CrossViewPart>(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<HTMLElement>(ctx, "standardSlot"));
return frame.root;
},
});