/* ============================================================================= * 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;