- 2-1 선택 해제 복구: 목록 재클릭 = 해제(계곡 통과 시설 포함 — 기존엔 시설이 항상 재선택됨). 해제는 리셋 버튼과 같은 전체 초기화(resetForm — 구조물군·종류· 측점·옵션)이며, 시설 해제는 onPipeSelect(null)로 그래프·3D·배수유역도까지 푼다. - 2-2 입력 순서 가이드: 측점(점형 기준, 구간형 시작·종료) 미입력이면 옵션 칸·부속 서브폼 잠금(disabled+inert+is-locked 흐림). 입력 즉시 해제. - 2-3 월류폭·월류 높이: 기본값 세월교 10m·물넘이 5m. 월류 높이 칸 신설 — fordSection(Manning 역산) 필요 수심을 자동 기입, 계산값 미만 입력은 되돌림. 저장 키 ford_height_m(0.01m 정밀도). - 2-3b A군 임시 배치: 기준 측점 입력 순간 관 지점에 임시로 넣어 세부유역·설계유량 미리보기. [추가]로 확정, 삭제·리셋·다른 항목 선택·종류 변경·외부 해제 시 관을 물려 직전 상태 복원. - 2-3c 우클릭 추가 개편: 종단도·테이블·배수유역도 [배관/구조물 추가]가 즉시 추가 대신 사이드 폼에 종류·측점을 자동 입력(A군은 임시 배치까지). BOX암거 기본 제원은 폼에 실려 유지. - 2-4 컨테이너 병합: 등고선 간격+시작 측점·샘플링+종단 설계 기준 → 「페이지 설정」. 측점 간격 입력 삭제(STATION_INTERVAL_M=20 고정, 서버 스키마 불변). 순서 = 구조물 배치 > 페이지 설정 > 경로 계산 설정. - 2-5 경로 계산 설정 비활성화: 사용여부 미결정 — 본문 inert(조작 불가) + is-disabled 흐림. 제목·접기는 유지. 검증: tsc --noEmit 무오류, vite build 성공. 상세는 PLAN.md 2-6. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
511 lines
23 KiB
TypeScript
511 lines
23 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";
|
|
gradeClass: "trunk" | "branch" | "work";
|
|
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;
|
|
}
|
|
|
|
/**
|
|
* 「임도설치 및 관리 등에 관한 규정」[별표 1-2] 기준값.
|
|
* 등급을 바꾸면 이 값이 계획선 폼의 placeholder(미입력 시 서버 기본값)로 반영된다.
|
|
* 실제 기본값 결정은 서버 config가 단일 소스이며 여기서는 안내만 한다.
|
|
*/
|
|
/** 측점 간격(m) — 20m 고정(2026-08-18 사용자 확정, 입력 UI 삭제). */
|
|
const STATION_INTERVAL_M = 20;
|
|
|
|
const PROFILE_CRITERIA: Record<
|
|
RoutePanelValues["gradeClass"],
|
|
{ speed: number; grade: Record<RoutePanelValues["terrainType"], number>; radius: number }
|
|
> = {
|
|
trunk: { speed: 40, grade: { normal: 7, special: 10 }, radius: 450 },
|
|
branch: { speed: 30, grade: { normal: 8, special: 12 }, radius: 250 },
|
|
work: { speed: 20, grade: { normal: 9, special: 14 }, radius: 100 },
|
|
};
|
|
|
|
interface PanelCallbacks {
|
|
onSolve: () => void;
|
|
/** [임시저장] — 현재 편집(계획선 델타·관로·비정규 측점·상단측)을 확정 전이 없이 저장. */
|
|
onTempSave: () => void;
|
|
/** [횡단 이동] — 저장 없이 B06 횡단 페이지로 이동만. */
|
|
onGoCross: () => void;
|
|
/** [초기화] — 사용자 편집을 버리고 초기 자동 계산 상태로 롤백. */
|
|
onReset: () => void;
|
|
onContourApply: (interval: number) => void;
|
|
onSurfaceVisible: (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;
|
|
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),
|
|
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 사용자 요청).
|
|
toggleButton("흑백 지형", false, callbacks.onSurfaceGrayscale),
|
|
);
|
|
const separator1 = document.createElement("span");
|
|
separator1.className = "b05-route__view-separator";
|
|
const separator2 = separator1.cloneNode() as HTMLSpanElement;
|
|
viewControls.append(
|
|
viewButtons,
|
|
separator1,
|
|
visibilityButtons,
|
|
separator2,
|
|
button("뷰 초기화", callbacks.onResetView, "glass"),
|
|
);
|
|
|
|
// 등고선 간격 행 — 「페이지 설정」 섹션에 들어간다(2026-08-18 컨테이너 병합).
|
|
const contourInterval = numberField("간격 (m), 최소 0.5m", "1");
|
|
const contourRow = document.createElement("div");
|
|
contourRow.className = "b05-route__contour-row";
|
|
contourRow.append(
|
|
contourInterval.wrapper,
|
|
button("재적용", () => callbacks.onContourApply(Number(contourInterval.value) || 1)),
|
|
);
|
|
|
|
// 포인트 팔레트 + 임도 기준·옵션을 한 컨테이너로 병합하고 [최적 경로 계산]도 이 안에
|
|
// 둔다 — 경로 재탐색은 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;
|
|
const gradeField = createSelectField({
|
|
label: "임도 등급",
|
|
options: [
|
|
{ value: "trunk", text: "간선" },
|
|
{ value: "branch", text: "지선" },
|
|
{ value: "work", text: "작업" },
|
|
],
|
|
});
|
|
const gradeClass = gradeField.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,
|
|
);
|
|
const solveButton = button("최적 경로 계산", callbacks.onSolve, "filled");
|
|
routeCalc.body.append(
|
|
algorithmField.root,
|
|
gradeField.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"));
|
|
sectionOptions.body.append(crossSampleInterval.wrapper, longSampleInterval.wrapper);
|
|
const terrainField = createSelectField({
|
|
label: "지형 구분",
|
|
options: [
|
|
{ value: "normal", text: "일반지형" },
|
|
{ value: "special", text: "특수지형" },
|
|
],
|
|
});
|
|
const terrainType = terrainField.select;
|
|
// 역기울기(5%) 상한 방향은 서버가 지반 형상에서 자동 판정(main_direction="auto")하므로
|
|
// 수동 선택 UI는 두지 않는다. 노선 균형 구역 길이도 자동 산출 기본값(전체 1구역)에 맡긴다.
|
|
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,
|
|
terrainField.root,
|
|
criteriaNote,
|
|
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,
|
|
});
|
|
|
|
/** 등급·지형 선택에 맞춰 법정 기준값을 placeholder와 안내문에 반영한다. */
|
|
function syncCriteria(): void {
|
|
const criteria = PROFILE_CRITERIA[gradeClass.value as RoutePanelValues["gradeClass"]];
|
|
const terrain = terrainType.value as RoutePanelValues["terrainType"];
|
|
maxGradePct.placeholder = String(criteria.grade[terrain]);
|
|
minVerticalRadius.placeholder = String(criteria.radius);
|
|
minTangentLength.placeholder = "20";
|
|
criteriaNote.textContent =
|
|
`설계속도 ${criteria.speed}km/h 기준 — 종단기울기 ${criteria.grade[terrain]}% 이하, ` +
|
|
`종단곡선 반경 ${criteria.radius}m 이상. 비워두면 이 기준이 적용됩니다.`;
|
|
}
|
|
gradeClass.addEventListener("change", syncCriteria);
|
|
terrainType.addEventListener("change", syncCriteria);
|
|
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,
|
|
terrainType,
|
|
maxGradePct,
|
|
minVerticalRadius,
|
|
minTangentLength,
|
|
startElevationOffset,
|
|
endElevationOffset,
|
|
];
|
|
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,
|
|
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),
|
|
terrainType: terrainType.value as RoutePanelValues["terrainType"],
|
|
maxGradePct: parseOptional(maxGradePct),
|
|
minVerticalRadius: parseOptional(minVerticalRadius),
|
|
minTangentLength: parseOptional(minTangentLength),
|
|
startElevationOffset: parseOptional(startElevationOffset),
|
|
endElevationOffset: parseOptional(endElevationOffset),
|
|
};
|
|
},
|
|
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.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);
|
|
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>;
|