454 lines
19 KiB
TypeScript
454 lines
19 KiB
TypeScript
import type { PlacedRoutePoint, RoutePointKind } from "./B05_wf2_Route_UI_Markers";
|
|
import {
|
|
createIrregularStationsSection,
|
|
type IrregularStation,
|
|
type IrregularStationsSection,
|
|
} from "./B05_wf2_Route_UI_IrregularStations";
|
|
import { type ButtonVariant, createButton } 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;
|
|
onConfirm: () => void;
|
|
onContourApply: (interval: number) => void;
|
|
onSurfaceVisible: (visible: boolean) => void;
|
|
onContoursVisible: (visible: boolean) => void;
|
|
onAxesVisible: (visible: boolean) => void;
|
|
onStationLinesVisible: (visible: boolean) => void;
|
|
onView: (view: "iso" | "top" | "front" | "side") => void;
|
|
onResetView: () => void;
|
|
onMovePoint: () => void;
|
|
onDeletePoint: () => void;
|
|
onRadiusChange: (radius: number) => void;
|
|
onInputChange: () => void;
|
|
/** 비정규 측점 목록이 바뀔 때(추가·수정·삭제·리셋). */
|
|
onIrregularChange: (stations: IrregularStation[]) => void;
|
|
/** 비정규 측점을 목록에서 선택/해제할 때 해당 측점(또는 null). */
|
|
onIrregularSelect: (station: IrregularStation | null) => void;
|
|
}
|
|
|
|
type WrappedInput = HTMLInputElement & { wrapper: HTMLLabelElement };
|
|
|
|
function L(key: keyof typeof ui_locales): string {
|
|
return ui_locales[key][currentLanguageIndex];
|
|
}
|
|
|
|
function section(title: string): { root: HTMLElement; body: HTMLElement } {
|
|
const root = document.createElement("section");
|
|
root.className = "b05-route__panel-section ui-collapsible";
|
|
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),
|
|
);
|
|
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("등고선 간격");
|
|
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);
|
|
|
|
const palette = section("포인트 팔레트");
|
|
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);
|
|
});
|
|
palette.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);
|
|
|
|
const conditions = section("임도 기준·옵션");
|
|
const algorithm = document.createElement("select");
|
|
algorithm.innerHTML =
|
|
'<option value="dijkstra">Dijkstra</option><option value="ridge_valley">능선·계곡</option>';
|
|
const gradeClass = document.createElement("select");
|
|
gradeClass.innerHTML =
|
|
'<option value="trunk">간선</option><option value="branch">지선</option><option value="work">작업</option>';
|
|
const algorithmLabel = document.createElement("label");
|
|
algorithmLabel.className = "b05-route__field";
|
|
algorithmLabel.append(document.createTextNode("알고리즘"), algorithm);
|
|
const gradeLabel = document.createElement("label");
|
|
gradeLabel.className = "b05-route__field";
|
|
gradeLabel.append(document.createTextNode("임도 등급"), gradeClass);
|
|
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,
|
|
);
|
|
conditions.body.append(algorithmLabel, gradeLabel, paved.wrapper, avoidPass.wrapper, details);
|
|
|
|
const sectionOptions = section(L("B05_Route_Group_SectionOptions"));
|
|
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,
|
|
);
|
|
|
|
const gradeLine = section("계획선(시공계획고) 설계");
|
|
const terrainType = document.createElement("select");
|
|
terrainType.innerHTML =
|
|
'<option value="normal">일반지형</option><option value="special">특수지형</option>';
|
|
const terrainLabel = document.createElement("label");
|
|
terrainLabel.className = "b05-route__field";
|
|
terrainLabel.append(document.createTextNode("지형 구분"), terrainType);
|
|
// 역기울기(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(terrainLabel, criteriaNote, gradeAdvanced);
|
|
|
|
// 비정규 측점(구조물 측점) — 측점번호+잔여거리로 추가/수정/삭제. 목록 변경은 Page로 올려
|
|
// 그래프·테이블·3D에 반영한다. chainage 환산 기준인 측점간격은 실시간 조회한다.
|
|
const irregular = createIrregularStationsSection({
|
|
getInterval: () => Number(stationInterval.value) || 20,
|
|
onChange: callbacks.onIrregularChange,
|
|
onSelect: callbacks.onIrregularSelect,
|
|
});
|
|
|
|
/** 등급·지형 선택에 맞춰 법정 기준값을 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();
|
|
|
|
const result = section("경로 설계 산출 결과");
|
|
const stale = document.createElement("span");
|
|
stale.className = "b05-route__stale";
|
|
stale.textContent = "재탐색 필요";
|
|
stale.hidden = true;
|
|
const metrics = document.createElement("div");
|
|
metrics.className = "b05-route__metrics";
|
|
metrics.textContent = "경로를 계산하면 결과가 표시됩니다.";
|
|
result.body.append(stale, metrics);
|
|
|
|
const solveButton = button("최적 경로 계산", callbacks.onSolve, "filled");
|
|
const confirmButton = button("경로 확정", callbacks.onConfirm);
|
|
confirmButton.disabled = true;
|
|
const actionRow = document.createElement("div");
|
|
actionRow.className = "b05-route__actions";
|
|
actionRow.append(solveButton, confirmButton);
|
|
|
|
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(
|
|
contour.root,
|
|
palette.root,
|
|
selected.root,
|
|
conditions.root,
|
|
sectionOptions.root,
|
|
gradeLine.root,
|
|
irregular.root,
|
|
result.root,
|
|
actionRow,
|
|
);
|
|
|
|
// 컨테이너 제목 행 전체 클릭 시 본문을 접거나 편다(공용 collapsible). 내부 details 등 별도
|
|
// 접힘 항목은 손대지 않는다.
|
|
attachCollapsible(root);
|
|
|
|
return {
|
|
root,
|
|
viewControls,
|
|
/** 비정규 측점 섹션 API(목록 조회·선택·초기화). 아직 백엔드로 보내지 않는다(프론트 프리뷰). */
|
|
irregularStations: irregular as IrregularStationsSection,
|
|
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);
|
|
},
|
|
setStale(value: boolean) {
|
|
stale.hidden = !value;
|
|
confirmButton.disabled = value;
|
|
},
|
|
setCanConfirm(value: boolean) {
|
|
confirmButton.disabled = !value;
|
|
},
|
|
renderMetrics(values: Record<string, unknown>) {
|
|
const rows = [
|
|
["총 연장", values.length_m],
|
|
["평균 경사", values.avg_grade_pct ?? values.mean_slope],
|
|
["최대 경사", values.max_grade_pct ?? values.max_slope],
|
|
["비용", values.cost_score],
|
|
["경사 초과", values.slope_violations],
|
|
["곡선반경 미달", values.curve_violations],
|
|
];
|
|
metrics.replaceChildren(
|
|
...rows.map(([label, value]) => {
|
|
const row = document.createElement("div");
|
|
row.textContent = `${label}: ${value ?? "-"}`;
|
|
return row;
|
|
}),
|
|
);
|
|
},
|
|
};
|
|
}
|
|
|
|
export type RoutePanel = ReturnType<typeof createRoutePanel>;
|