선택한 구조물의 폼 길이에 밀려 「구조물 배치」 섹션 아래 목록이 화면 밖으로 나가던 문제. 목록(<ul>)을 섹션 본문에서 빼 StructuresSection.listRoot로 노출하고, 패널이 하단 고정 영역(b05-route__dock)에 배치한다. 배치 순서: 상단 구분선(dock border-top) → 구조물 목록 → 중간 구분선 → [초기화][임시저장][횡단 이동]. - 공용 ui-sidebar-actions(sticky·배경·상단선)는 그대로 쓰고, 안쪽만 세로 배치로 되돌리는 .b05-route__dock 규칙 추가(공용 규칙보다 좁은 선택자로 로드 순서 무관). - 선택·폼 로드·스크롤 동작은 그대로 — DOM 부모만 바뀌었다. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
505 lines
22 KiB
TypeScript
505 lines
22 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가 단일 소스이며 여기서는 안내만 한다.
|
|
*/
|
|
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;
|
|
onPipeFacilitySelect: (chainageM: number) => 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"),
|
|
);
|
|
|
|
const contour = section("등고선 간격");
|
|
contour.root.classList.add("is-collapsed");
|
|
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)),
|
|
);
|
|
contour.body.append(contourRow);
|
|
|
|
// 포인트 팔레트 + 임도 기준·옵션을 한 컨테이너로 병합하고 [최적 경로 계산]도 이 안에
|
|
// 둔다 — 경로 재탐색은 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,
|
|
);
|
|
|
|
const sectionOptions = section(L("B05_Route_Group_SectionOptions"));
|
|
sectionOptions.root.classList.add("is-collapsed");
|
|
const stationInterval = numberField(L("B05_Route_Field_StationInterval"));
|
|
const crossSampleInterval = numberField(L("B05_Route_Field_CrossSample"));
|
|
const longSampleInterval = numberField(L("B05_Route_Field_LongSample"));
|
|
sectionOptions.body.append(
|
|
stationInterval.wrapper,
|
|
crossSampleInterval.wrapper,
|
|
longSampleInterval.wrapper,
|
|
);
|
|
|
|
// 종단 설계 기준은 한 번 정하면 자주 손대지 않는다 — 기본은 접힌 상태
|
|
// (2026-08-17 사용자 지시).
|
|
const gradeLine = section("종단 설계 기준", true);
|
|
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,
|
|
);
|
|
gradeLine.body.append(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: () => Number(stationInterval.value) || 20,
|
|
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,
|
|
stationInterval,
|
|
crossSampleInterval,
|
|
longSampleInterval,
|
|
terrainType,
|
|
maxGradePct,
|
|
minVerticalRadius,
|
|
minTangentLength,
|
|
startElevationOffset,
|
|
endElevationOffset,
|
|
];
|
|
inputElements.forEach((input) => input.addEventListener("change", callbacks.onInputChange));
|
|
root.append(
|
|
gradeLine.root,
|
|
contour.root,
|
|
sectionOptions.root,
|
|
structures.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: parseOptional(stationInterval),
|
|
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;
|
|
if (values.stationInterval != null) stationInterval.value = String(values.stationInterval);
|
|
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>;
|