좌측 「계획노선」 섹션의 [계획노선 편집] 로 큰 모달을 엶(PLAN 0-2). - 등고선 도엽 위에 예상노선(점선)·계획노선(실선)을 함께 그림. 지도 그리기는 배수유역도와 같은 도구(`B04_PreProcess_UI_MapRender`) 재사용. - 노드 끌어 옮기기 · 선 두 번 클릭으로 노드 끼우기 · 오른쪽 클릭으로 지우기, 배경 끌기로 화면 이동, 휠로 확대. - 편집 중에는 계산이 나가지 않음. [확인]에서만 서버가 배수유역부터 재계산하며 그동안 화면을 덮는 안내를 띄움. 끝나면 세션 초안·조회 캐시를 비우고 페이지를 다시 세움. - [예상노선으로] 는 수정본을 지우고 같은 재계산(노선 초기화). Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
715 lines
35 KiB
TypeScript
715 lines
35 KiB
TypeScript
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;
|
||
/** [계획노선 편집] — 큰 모달을 열어 노선을 고친다(계산은 모달 [확인]에서만 돈다). */
|
||
onEditPlannedRoute: () => 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;
|
||
/** 직교/원근 전환 — 탑뷰에서 크기를 정밀 대조할 때만 직교로 본다(2026-09-04 사용자 지시). */
|
||
onProjection: (kind: "perspective" | "ortho") => 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;
|
||
/** 바깥(3D·종단·유역도)에서 고른 것이 폼에 실릴 때 — 접힌 좌측 패널을 펼친다. */
|
||
onStructureReveal?: () => 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")),
|
||
);
|
||
// 직교/원근 전환(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()),
|
||
),
|
||
);
|
||
|
||
// 「구조물 배치」 — 구 비정규 측점 섹션을 흡수한 단일 섹션(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,
|
||
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,
|
||
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,
|
||
plannedRoute.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>;
|