Files
Aislo/B05_Profile/B05_Profile_UI_Panel.ts
T
eomsangdonandClaude Opus 5 3651ad9875 feat(B05): 종단 계획선 전체 측점 폴리라인 전환 + 직선화·쉬프트·undo/redo 신설
2026-09-02 사용자 지시 9건 반영. 화면 실조작 검증은 다음 세션 몫 (사용자 지시로
코딩까지만 진행).

1. 초기 계획선 = 전체 측점 폴리라인 — `design_ground_following_profile()` 신설.
   모든 측점을 변화점으로 잡고 계획고 = 원지반고, 라운드는 R을 지정한 자리에만
   (`build_curves(only_explicit=)`·`build_alignment(only_explicit_curves=)`).
   basis `ground_polyline`. 관 정착 선형은 폴백으로 내림.
2. 구간 쉬프트(⬆⬇) 삭제.
3. [직선화] 신설 — `B05_Profile_UI_Profile_Straighten.ts`. 두 측점의 라운드에 탄젠트한
   직선으로 대체하고 사이 라운드 삭제. 직선 틸팅 시 가운데 라운드 + 양측 탄젠트 재구성.
4. [쉬프트] 신설 — 직선 구간을 기하에서 되읽어(`detectStraightRun`) 복수 선택,
   최외곽 라운드 중심 기준 상·하 평행이동.
5. 방향키 조작 — 상하 0.1m 계획고, 좌우 0.1m 누가거리(구조물·비정규 측점 한정).
6. undo/redo 신설 — B05·B06 조작 세션 키 묶음 스냅샷(`_Profile_History.ts`).
   버튼은 요약줄 맨 앞(최대 기울기 좌측), 21x17px. Ctrl+Z / Ctrl+Shift+Z.
7. 종단 요약줄의 횡단배수 최소고 표시 삭제(산식·편집 차단은 유지).
8. [편집 되돌리기] 버튼 삭제 — undo/redo로 대체.
9. 지형 구분 기본값 특수지형 — 패널 셀렉트와 백엔드 기본값(스키마·체인 폴백) 일치.

700줄 제한 — 패널을 `_Profile_Panel_Tools` · `_Profile_Preview` 로 분리하고 측점↔구조물
짝짓기를 `_Profile_Structures` 로 이관(842줄 → 696줄).

검증: tsc --noEmit 오류 0, ruff format/check 통과,
pytest tmp/tests/ -q → 366 passed / 14 skipped / 0 failed.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-09-02 19:30:16 +09:00

673 lines
33 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
import type { PlacedRoutePoint, RoutePointKind } 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";
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],
};
interface PanelCallbacks {
onSolve: () => void;
/** [임시저장] — 현재 편집(계획선 델타·관로·비정규 측점·상단측)을 확정 전이 없이 저장. */
onTempSave: () => void;
/** [횡단 이동] — 저장 없이 B06 횡단 페이지로 이동만. */
onGoCross: () => void;
/** [초기화] — 사용자 편집을 버리고 초기 자동 계산 상태로 롤백. */
onReset: () => void;
onContourApply: (interval: number) => void | Promise<void>;
onSurfaceVisible: (visible: boolean) => void;
/** [예상형상] — 계획 코리도 서피스 + 클리핑 지형(공사 후) ↔ 원지반 완전체 전환. */
onCorridorVisible: (visible: boolean) => void;
onContoursVisible: (visible: boolean) => void;
onAxesVisible: (visible: boolean) => void;
onStationLinesVisible: (visible: boolean) => void;
/** 측점 번호·이름 라벨 표시 토글(기본 켜짐 — BP·EP·5측점 배수·구조물). */
onStationLabelsVisible: (visible: boolean) => void;
/** 지표면 흑백 표시 토글(기본 꺼짐 — 무지개 고도색). */
onSurfaceGrayscale: (grayscale: boolean) => void;
onView: (view: "iso" | "top" | "front" | "side") => void;
onResetView: () => void;
/** [3D 업데이트] — 계획선 편집을 3D 예상형상·측점선에 한 번에 반영(2026-09-01 사용자
* 지시). 편집마다 따라오던 자동 갱신을 없애고 이 버튼으로만 돌린다. */
onCorridorRefresh: () => void | Promise<void>;
onMovePoint: () => void;
onDeletePoint: () => void;
onRadiusChange: (radius: number) => void;
onInputChange: () => void;
/** 구조물 목록(구조물군 B~G)이 바뀔 때 — 정본 저장·그래프 반영은 Page가 맡는다. */
onStructuresChange: (structures: StructureInstance[]) => void;
/** 구조물을 목록에서 선택/해제할 때 해당 구조물(또는 null). */
onStructureSelect: (structure: StructureInstance | null) => void;
/** 계곡 통과 시설 추가·수정·삭제·선택 — 관 지점 정본(배수유역 패널) 경유(2026-08-17 통합). */
onPipeFacilityAdd: (chainageM: number, attributes: FacilityAttributes) => void;
onPipeFacilityUpdate: (
fromChainageM: number,
toChainageM: number,
attributes: FacilityAttributes,
) => void;
onPipeFacilityRemove: (chainageM: number) => void;
/** null = 사이드 목록 재클릭 해제 — 전 화면 선택도 함께 푼다(2026-08-18). */
onPipeFacilitySelect: (chainageM: number | null) => void;
/** 이어 공사 시작 기준(시작 측점·누가거리 시작)이 바뀔 때. */
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")),
);
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 사용자 지시).
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,
);
const solveButton = button("최적 경로 계산", callbacks.onSolve, "filled");
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()),
),
);
// 「구조물 배치」 — 구 비정규 측점 섹션을 흡수한 단일 섹션(2026-08-17 컨테이너 병합).
// 타입 목록과 옵션 칸은 서버 레지스트리에서 받아 그리고, 계곡 통과 시설(A군)은 관
// 지점 정본과 연동한다. 위치 입출력은 측점번호+잔여거리 두 칸이다.
const structures = createStructuresSection({
onChange: callbacks.onStructuresChange,
onSelect: callbacks.onStructureSelect,
getInterval: () => STATION_INTERVAL_M,
onPipeAdd: callbacks.onPipeFacilityAdd,
onPipeUpdate: callbacks.onPipeFacilityUpdate,
onPipeRemove: callbacks.onPipeFacilityRemove,
onPipeSelect: callbacks.onPipeFacilitySelect,
});
/** 지금 적용되는 종단기울기 상한(%) — 사용자가 값을 넣었으면 그 값, 비웠으면
* 등급·지형의 법정 기준. 종단 그래프의 위반 판정이 이 값을 즉시 따라온다
* (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,
paved,
avoidPass,
minCurveRadius,
maxUphillGrade,
maxDownhillGrade,
minUphillGrade,
minDownhillGrade,
crossSampleInterval,
longSampleInterval,
designSpeed,
terrainType,
maxGradePct,
minVerticalRadius,
minTangentLength,
startElevationOffset,
endElevationOffset,
enforcePipeClearance,
];
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, routeCalc.root, selected.root, actionDock);
// 컨테이너 제목 행 전체 클릭 시 본문을 접거나 편다(공용 collapsible). 내부 details 등 별도
// 접힘 항목은 손대지 않는다.
attachCollapsible(root);
return {
root,
viewControls,
/** 「구조물 배치」 섹션 API(타입 주입·목록 교체·계곡 시설 병합·선택·추가/이동/삭제). */
structures: structures as StructuresSection,
/** 이어 공사 시작 기준(측점번호·누가거리 오프셋) 현재값. */
stationDisplayOffset,
/** 지금 적용되는 종단기울기 상한(%) — 그래프 위반 판정 즉시 반영용(2026-08-19). */
gradeLimitPct,
/** [3D 업데이트] 대기 표시 — 편집이 3D에 아직 안 들어갔음을 버튼에 알린다. */
setCorridorPending(pending: boolean) {
corridorRefresh.classList.toggle("is-pending", pending);
},
values(): RoutePanelValues {
return {
contourInterval: Number(contourInterval.value) || 1,
algorithm: algorithm.value as RoutePanelValues["algorithm"],
gradeClass: gradeClass.value as RoutePanelValues["gradeClass"],
paved: paved.checked,
minCurveRadius: parseOptional(minCurveRadius),
maxUphillGrade: parseOptional(maxUphillGrade),
maxDownhillGrade: parseOptional(maxDownhillGrade),
minUphillGrade: parseOptional(minUphillGrade),
minDownhillGrade: parseOptional(minDownhillGrade),
allowAvoidPassThrough: avoidPass.checked,
stationInterval: STATION_INTERVAL_M,
crossSampleInterval: parseOptional(crossSampleInterval),
longSampleInterval: parseOptional(longSampleInterval),
designSpeed: currentSpeed(),
terrainType: terrainType.value as RoutePanelValues["terrainType"],
maxGradePct: parseOptional(maxGradePct),
minVerticalRadius: parseOptional(minVerticalRadius),
minTangentLength: parseOptional(minTangentLength),
startElevationOffset: parseOptional(startElevationOffset),
endElevationOffset: parseOptional(endElevationOffset),
enforcePipeClearance: enforcePipeClearance.checked,
};
},
restore(values: Partial<RoutePanelValues>) {
if (values.contourInterval != null) contourInterval.value = String(values.contourInterval);
if (values.algorithm) algorithm.value = values.algorithm;
if (values.gradeClass) gradeClass.value = values.gradeClass;
if (values.paved != null) paved.checked = values.paved;
if (values.minCurveRadius != null) minCurveRadius.value = String(values.minCurveRadius);
if (values.maxUphillGrade != null) maxUphillGrade.value = String(values.maxUphillGrade);
if (values.maxDownhillGrade != null) maxDownhillGrade.value = String(values.maxDownhillGrade);
if (values.minUphillGrade != null) minUphillGrade.value = String(values.minUphillGrade);
if (values.minDownhillGrade != null) minDownhillGrade.value = String(values.minDownhillGrade);
if (values.allowAvoidPassThrough != null) avoidPass.checked = values.allowAvoidPassThrough;
// stationInterval은 복원하지 않는다 — 20m 고정(2026-08-18).
if (values.crossSampleInterval != null)
crossSampleInterval.value = String(values.crossSampleInterval);
if (values.longSampleInterval != null)
longSampleInterval.value = String(values.longSampleInterval);
if (values.gradeClass) {
gradeClass.value = values.gradeClass;
syncSpeedChoices();
}
if (values.designSpeed) designSpeed.value = String(values.designSpeed);
if (values.terrainType) terrainType.value = values.terrainType;
if (values.maxGradePct != null) maxGradePct.value = String(values.maxGradePct);
if (values.minVerticalRadius != null)
minVerticalRadius.value = String(values.minVerticalRadius);
if (values.minTangentLength != null) minTangentLength.value = String(values.minTangentLength);
if (values.startElevationOffset != null)
startElevationOffset.value = String(values.startElevationOffset);
if (values.endElevationOffset != null)
endElevationOffset.value = String(values.endElevationOffset);
if (values.enforcePipeClearance != null)
enforcePipeClearance.checked = values.enforcePipeClearance;
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);
},
};
}
export type RoutePanel = ReturnType<typeof createRoutePanel>;