Files
Aislo/B05_Profile/B05_Profile_UI_Panel.ts
T
eomsangdonandClaude Fable 5 98df22f789 feat(B05,B06): 워크플로우 재정의 — 자동 계산 노란 상태·버튼 개편·초기화
자동 체인 상태 전이 분리:
- confirm_latest_route/confirm_sections에 mark_stage_complete 플래그 추가
- 자동 체인(신규·재확정)은 데이터만 확정하고 stage 2·3을 IN_PROGRESS로 남김
  (스텝바 기존 노란 테두리 = 계산됨·확정 전 상태로 재사용)
- B06 [확정]이 stage 2+3 동시 완료 + 경로 상태 CONFIRMED 보강 후 B07 수량 이동

B05 좌측 패널 개편:
- 포인트 팔레트 + 임도 기준·옵션을 경로 계산 설정 한 컨테이너로 병합,
  [최적 경로 계산] 버튼을 컨테이너 내부로 이동 (가끔 쓰는 무거운 재계산)
- 하단 액션 행 [초기화][임시저장][횡단 이동]으로 교체, 경로 단독 확정 폐지
- [임시저장] = 관로 + 계획선 델타 + 비정규 측점·상단측 저장(mark_stage_complete=false)
- [횡단 이동]/B06 [종단 이동] = 저장 없이 페이지 이동만 (B05·B06 캐시 공유 구조 유지)
- [초기화] = POST /route/reset 신설: 기존 경로 삭제 후 계획노선 CSV 기본값으로
  자동 체인 재실행(파일입력 직후 상태 복원), 분석용 타임아웃 적용

B06 액션 행: [종단 이동][임시저장][확정] 3버튼 구성, locale 키 정비

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-08-08 10:26:45 +09:00

482 lines
21 KiB
TypeScript

import type { PlacedRoutePoint, RoutePointKind } from "./B05_Profile_UI_Markers";
import {
createIrregularStationsSection,
type IrregularStation,
type IrregularStationsSection,
} from "./B05_Profile_UI_IrregularStations";
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;
/** 비정규 측점 목록이 바뀔 때(추가·수정·삭제·리셋). */
onIrregularChange: (stations: IrregularStation[]) => void;
/** 비정규 측점을 목록에서 선택/해제할 때 해당 측점(또는 null). */
onIrregularSelect: (station: IrregularStation | 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): { root: HTMLElement; body: HTMLElement } {
const root = document.createElement("section");
// ui-sidebar-section: 사이드 컨테이너 공통 외곽선(진하게, 2026-08-05 사용자 지시).
root.className = "b05-route__panel-section ui-collapsible ui-sidebar-section";
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,
);
const gradeLine = section("종단 설계 기준");
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()),
),
);
// 비정규 측점(구조물 측점) — 측점번호+잔여거리로 추가/수정/삭제. 목록 변경은 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();
// 하단 고정 액션 행: [초기화][임시저장][횡단 이동] — 경로 확정 개념 폐지, 종·횡 통합
// 확정은 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");
// 사이드 최하단 고정(공용 ui-sidebar-actions) — 스크롤에서 제외(2026-08-05 사용자 지시).
actionRow.className = "b05-route__actions ui-sidebar-actions";
actionRow.append(resetButton, tempSaveButton, goCrossButton);
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,
irregular.root,
routeCalc.root,
selected.root,
actionRow,
);
// 컨테이너 제목 행 전체 클릭 시 본문을 접거나 편다(공용 collapsible). 내부 details 등 별도
// 접힘 항목은 손대지 않는다.
attachCollapsible(root);
return {
root,
viewControls,
/** 비정규 측점 섹션 API(목록 조회·선택·초기화). 아직 백엔드로 보내지 않는다(프론트 프리뷰). */
irregularStations: irregular as IrregularStationsSection,
/** 이어 공사 시작 기준(측점번호·누가거리 오프셋) 현재값. */
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>;