feat(B05): 사이드 패널 입력 로직·레이아웃 개편 (PLAN 2-1~2-5)

- 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>
This commit is contained in:
2026-08-18 18:30:18 +09:00
co-authored by Claude Opus 5
parent d192343012
commit 3c05052418
9 changed files with 308 additions and 107 deletions
@@ -259,33 +259,66 @@ export function createFacilityOptionsForm(
const fordCount = numberInput("1", "1", "련");
const fordRow = grid(labeled("수량 (련)", fordCount));
// ── 물넘이·세월교 개략 단면 — 지정할 옵션이 거의 없으니 계산값이라도 보여 준다
// (2026-08-17 사용자 지시). 폭만 받고 설계유량으로 수심·통수능을 되짚는다.
// ── 물넘이·세월교 개략 단면 — 월류 폭 + 월류 높이 한 행(2026-08-18 사용자 지시).
// 폭 기본값은 세월교 10m·물넘이 포장 5m(사용자 확정 — 지식DB 폭 수치 근거 없음).
// 높이는 설계유량·폭으로 되짚은 필요 수심을 자동으로 채우고, 사용자가 키울 수는
// 있으나 계산값(필요 최소 수심) 미만은 되돌린다.
const fordWidth = numberInput("0.1");
const fordWidthRow = grid(labeled("월류 폭 (m)", fordWidth));
const fordHeight = numberInput("0.01");
const fordWidthRow = grid(
labeled("월류 폭 (m)", fordWidth),
labeled("월류 높이 (m)", fordHeight),
);
const fordSummary = document.createElement("p");
fordSummary.className = "b05-drainage__facility-note";
/** 담당 유역 설계유량(㎥/s) — 개략 단면의 입력. 유역이 없으면 null. */
let designFlowM3s: number | null = null;
/** 현재 조건(설계유량·월류 폭)의 필요 최소 수심(m). 계산 불가면 null. */
let fordMinDepthM: number | null = null;
function syncFordSummary(): void {
const section =
designFlowM3s !== null
? fordSection(designFlowM3s, Number.parseFloat(fordWidth.value))
: null;
fordMinDepthM = section ? section.depthM : null;
if (section) {
// 표시 정밀도(0.01m)로 맞춘 최소값 — 비었거나 그보다 작으면 계산값으로 채운다.
const min = Number(section.depthM.toFixed(2));
const current = Number.parseFloat(fordHeight.value);
if (!Number.isFinite(current) || current < min) fordHeight.value = min.toFixed(2);
}
if (designFlowM3s === null) {
fordSummary.textContent =
"담당 유역의 설계유량이 아직 없습니다 — [유역 분석] 후 개략 단면이 나옵니다.";
return;
}
const section = fordSection(designFlowM3s, Number.parseFloat(fordWidth.value));
const head = `설계유량 ${designFlowM3s.toFixed(3)} ㎥/s`;
if (!section) {
fordSummary.textContent = `${head} · 월류 폭을 넣으면 필요 수심·단면을 계산합니다.`;
return;
}
// 필요 최소 수심이다 — 실제 설계 수심은 여기에 여유를 더해 정한다.
// 필요 최소 수심이다 — 월류 높이는 이 값 아래로 내릴 수 없다.
fordSummary.textContent =
`${head} · 필요 수심 ${section.depthM.toFixed(2)} m · ` +
`필요 단면 ${section.areaM2.toFixed(2)} ㎡ · 유속 ${section.velocityMs.toFixed(2)} m/s`;
`필요 단면 ${section.areaM2.toFixed(2)} ㎡ · 유속 ${section.velocityMs.toFixed(2)} m/s` +
`월류 높이는 필요 수심 이상만 입력됩니다.`;
}
// 계산값 미만은 입력을 받지 않는다 — 계산값으로 되돌리고 잠깐 붉힌다.
fordHeight.addEventListener("change", () => {
if (fordMinDepthM !== null) {
const min = Number(fordMinDepthM.toFixed(2));
const value = Number.parseFloat(fordHeight.value);
if (!Number.isFinite(value) || value < min) {
fordHeight.value = min.toFixed(2);
fordHeight.classList.add("is-invalid");
window.setTimeout(() => fordHeight.classList.remove("is-invalid"), 900);
}
}
emit();
});
root.append(
pipeRow,
inletGroup.root,
@@ -337,6 +370,12 @@ export function createFacilityOptionsForm(
}
}
/** 월류 높이는 cm 단위 수심이라 0.01m 정밀도로 싣는다(putNumber는 0.1m 반올림). */
function putFordHeight(options: Record<string, string | number>): void {
const value = Number.parseFloat(fordHeight.value);
if (Number.isFinite(value) && value > 0) options.ford_height_m = Number(value.toFixed(2));
}
function emit(): void {
syncVisibility();
callbacks.onChange?.();
@@ -404,6 +443,11 @@ export function createFacilityOptionsForm(
wingOutFields.write(options);
fordCount.value = isFord ? text("pipe_count") : "";
fordWidth.value = text("ford_width_m");
fordHeight.value = text("ford_height_m");
// 월류 폭 기본값 — 세월교 10m·물넘이 포장 5m(2026-08-18 사용자 확정).
if (!fordWidth.value && (facility === "ford_pavement" || facility === "ford_bridge")) {
fordWidth.value = facility === "ford_bridge" ? "10" : "5";
}
syncVisibility();
},
readOptions() {
@@ -430,12 +474,14 @@ export function createFacilityOptionsForm(
wingOutFields.read(options);
} else if (current === "ford_pavement") {
putNumber(options, "ford_width_m", fordWidth.value);
putFordHeight(options);
} else if (current === "ford_bridge") {
options.pipe_kind = pipeMaterial.value;
options.pipe_diameter_mm = Number(pipeDiameter.value);
const count = Number.parseInt(fordCount.value, 10);
if (Number.isFinite(count) && count > 0) options.pipe_count = count;
putNumber(options, "ford_width_m", fordWidth.value);
putFordHeight(options);
}
return options;
},
+10 -2
View File
@@ -118,6 +118,9 @@ export interface DrainagePanelCallbacks {
onPipeSelected?: (chainageM: number | null) => void;
/** 배수유역도 우클릭으로 배관 외 구조물을 넣을 때(누가거리는 계획선 투영값). */
onStructureAdd?: (chainageM: number, type: "기성막이" | "대피로" | "기타") => void;
/** 배수유역도 우클릭 [배관 추가] — 즉시 추가 대신 사이드 폼에 측점을 실어 준다
* (2026-08-18 사용자 지시). */
onPipeAddRequest?: (chainageM: number) => void;
}
export function createDrainagePanel(callbacks: DrainagePanelCallbacks = {}): DrainagePanel {
@@ -530,8 +533,13 @@ export function createDrainagePanel(callbacks: DrainagePanelCallbacks = {}): Dra
}
}
bindPipeContextMenu(viewport, contextMenu, pipeEditor, currentView, (chainage, type) =>
callbacks.onStructureAdd?.(chainage, type),
bindPipeContextMenu(
viewport,
contextMenu,
pipeEditor,
currentView,
(chainage, type) => callbacks.onStructureAdd?.(chainage, type),
callbacks.onPipeAddRequest ? (chainage) => callbacks.onPipeAddRequest?.(chainage) : undefined,
);
bindDrainageInteractions({
+11 -1
View File
@@ -200,6 +200,9 @@ export function bindPipeContextMenu(
editor: PipeEditor,
viewOf: () => ViewState,
onAddStructure?: (chainageM: number, type: "기성막이" | "대피로" | "기타") => void,
/** [배관 추가] 위임 — 즉시 추가 대신 사이드 폼에 측점을 실어 준다(2026-08-18).
* 없으면 옛 동작(즉시 추가)으로 돌아간다. */
onAddPipe?: (chainageM: number) => void,
): void {
viewport.addEventListener("contextmenu", (event) => {
if (menu.contains(event.target)) {
@@ -230,7 +233,14 @@ export function bindPipeContextMenu(
// 배관 + 배관 외 구조물(기성막이/대피로/기타) 추가(2026-08-05 사용자 지시).
const chainage = editor.chainageAt(view, x, y);
menu.open(x, y, [
[L("B05_Drainage_Menu_Add"), () => void editor.addAt(view, x, y)],
[
L("B05_Drainage_Menu_Add"),
() => {
// 즉시 추가 폐지(2026-08-18) — 사이드 폼 경유. 위임이 없을 때만 옛 동작.
if (onAddPipe && chainage !== null) onAddPipe(chainage);
else void editor.addAt(view, x, y);
},
],
...(onAddStructure && chainage !== null
? (["기성막이", "대피로", "기타"] as const).map((type): [string, () => void] => [
`${type === "기타" ? "기타 구조물" : type} 추가`,
+10 -4
View File
@@ -111,7 +111,9 @@ export async function renderB05Route(root: HTMLElement): Promise<void> {
onStructureRemove: (station) => {
if (isPipeStation(station)) profilePanel.drainage.removePipe(station.chainage_m);
},
onPipeAdd: (chainage) => profilePanel.drainage.addPipe(chainage),
// 우클릭 [배관 추가] — 즉시 추가 대신 사이드 폼에 종류·측점을 실어 주고,
// A군 임시 배치(세부유역 미리보기)로 이어진다. [추가]로 확정(2026-08-18).
onPipeAdd: (chainage) => panel.structures.addAt(chainage, "pipe"),
// 우클릭 메뉴의 구 항목(기성막이·대피로·기타) — 구조물 정본 타입으로 넣는다.
onStructureAdd: (chainage, type) =>
panel.structures.addAt(chainage, LEGACY_TYPE_IDS[type] ?? "etc"),
@@ -252,9 +254,13 @@ export async function renderB05Route(root: HTMLElement): Promise<void> {
if (selectionSyncing) return;
selectionSyncing = true;
try {
const station = bridge
.irregularStations()
.find((entry) => Math.abs(entry.chainage_m - chainage) < 0.05);
// null = 사이드 재클릭 해제 — 그래프·3D·배수유역도 선택도 함께 푼다(2026-08-18).
const station =
chainage === null
? undefined
: bridge
.irregularStations()
.find((entry) => Math.abs(entry.chainage_m - chainage) < 0.05);
const id = station ? irregularStationId(station.id) : null;
viewer.markers.selectStation(id);
profilePanel.setSelectedStation(id);
+35 -29
View File
@@ -33,6 +33,9 @@ export interface RoutePanelValues {
* 등급을 바꾸면 이 값이 계획선 폼의 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 }
@@ -77,7 +80,8 @@ interface PanelCallbacks {
attributes: FacilityAttributes,
) => void;
onPipeFacilityRemove: (chainageM: number) => void;
onPipeFacilitySelect: (chainageM: number) => void;
/** null = 사이드 목록 재클릭 해제 — 전 화면 선택도 함께 푼다(2026-08-18). */
onPipeFacilitySelect: (chainageM: number | null) => void;
/** 이어 공사 시작 기준(시작 측점·누가거리 시작)이 바뀔 때. */
onStationDisplayChange: (offset: { station: number; cumulative: number }) => void;
}
@@ -192,8 +196,7 @@ export function createRoutePanel(callbacks: PanelCallbacks) {
button("뷰 초기화", callbacks.onResetView, "glass"),
);
const contour = section("등고선 간격");
contour.root.classList.add("is-collapsed");
// 등고선 간격 행 — 「페이지 설정」 섹션에 들어간다(2026-08-18 컨테이너 병합).
const contourInterval = numberField("간격 (m), 최소 0.5m", "1");
const contourRow = document.createElement("div");
contourRow.className = "b05-route__contour-row";
@@ -201,7 +204,6 @@ export function createRoutePanel(callbacks: PanelCallbacks) {
contourInterval.wrapper,
button("재적용", () => callbacks.onContourApply(Number(contourInterval.value) || 1)),
);
contour.body.append(contourRow);
// 포인트 팔레트 + 임도 기준·옵션을 한 컨테이너로 병합하고 [최적 경로 계산]도 이 안에
// 둔다 — 경로 재탐색은 B04~B06 재계산을 부르는 무거운 작업이라 가끔만 쓴다
@@ -286,20 +288,13 @@ export function createRoutePanel(callbacks: PanelCallbacks) {
solveButton,
);
const sectionOptions = section(L("B05_Route_Group_SectionOptions"));
// 「페이지 설정」 — 등고선 간격·시작 측점 및 샘플링·종단 설계 기준 세 섹션 병합
// (2026-08-18 사용자 확정). 측점 간격 입력은 삭제 — 20m 고정.
const sectionOptions = section("페이지 설정");
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);
sectionOptions.body.append(crossSampleInterval.wrapper, longSampleInterval.wrapper);
const terrainField = createSelectField({
label: "지형 구분",
options: [
@@ -328,7 +323,18 @@ export function createRoutePanel(callbacks: PanelCallbacks) {
startElevationOffset.wrapper,
endElevationOffset.wrapper,
);
gradeLine.body.append(terrainField.root, criteriaNote, gradeAdvanced);
// 병합 구획 구분선 — 샘플링 / 등고선 / 종단 설계 기준.
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.
@@ -359,7 +365,7 @@ export function createRoutePanel(callbacks: PanelCallbacks) {
const structures = createStructuresSection({
onChange: callbacks.onStructuresChange,
onSelect: callbacks.onStructureSelect,
getInterval: () => Number(stationInterval.value) || 20,
getInterval: () => STATION_INTERVAL_M,
onPipeAdd: callbacks.onPipeFacilityAdd,
onPipeUpdate: callbacks.onPipeFacilityUpdate,
onPipeRemove: callbacks.onPipeFacilityRemove,
@@ -409,7 +415,6 @@ export function createRoutePanel(callbacks: PanelCallbacks) {
maxDownhillGrade,
minUphillGrade,
minDownhillGrade,
stationInterval,
crossSampleInterval,
longSampleInterval,
terrainType,
@@ -420,15 +425,16 @@ export function createRoutePanel(callbacks: PanelCallbacks) {
endElevationOffset,
];
inputElements.forEach((input) => input.addEventListener("change", callbacks.onInputChange));
root.append(
gradeLine.root,
contour.root,
sectionOptions.root,
structures.root,
routeCalc.root,
selected.root,
actionDock,
);
// 경로 계산 설정 — 사용여부 미결정으로 조작 불가(숨기지 않는다, 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 등 별도
// 접힘 항목은 손대지 않는다.
@@ -453,7 +459,7 @@ export function createRoutePanel(callbacks: PanelCallbacks) {
minUphillGrade: parseOptional(minUphillGrade),
minDownhillGrade: parseOptional(minDownhillGrade),
allowAvoidPassThrough: avoidPass.checked,
stationInterval: parseOptional(stationInterval),
stationInterval: STATION_INTERVAL_M,
crossSampleInterval: parseOptional(crossSampleInterval),
longSampleInterval: parseOptional(longSampleInterval),
terrainType: terrainType.value as RoutePanelValues["terrainType"],
@@ -475,7 +481,7 @@ export function createRoutePanel(callbacks: PanelCallbacks) {
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);
// stationInterval은 복원하지 않는다 — 20m 고정(2026-08-18).
if (values.crossSampleInterval != null)
crossSampleInterval.value = String(values.crossSampleInterval);
if (values.longSampleInterval != null)
@@ -182,6 +182,8 @@ export function createRouteProfilePanel(
onBasinSelected: (chainageM) => callbacks?.onBasinSelected?.(chainageM),
onPipeSelected: (chainageM) => callbacks?.onPipeSelected?.(chainageM),
onStructureAdd: (chainage, type) => callbacks?.onStructureAdd?.(chainage, type),
// 배수유역도 [배관 추가]도 사이드 폼 경유(2026-08-18) — 테이블 우클릭과 같은 경로.
onPipeAddRequest: (chainage) => callbacks?.onPipeAdd?.(chainage),
});
content.append(bodyWrap, drainagePanel.root);
// 위쪽 경계를 끌어 패널 높이를 조절한다. 늘어난 만큼은 그래프만 먹고 도면 테이블은
+169 -65
View File
@@ -99,8 +99,9 @@ interface StructuresCallbacks {
) => void;
/** 계곡 통과 시설 삭제. */
onPipeRemove: (chainageM: number) => void;
/** 계곡 통과 시설을 목록에서 골랐을 때(시설 폼·유역 동기화는 배수유역 패널 몫). */
onPipeSelect: (chainageM: number) => void;
/** 계곡 통과 시설을 목록에서 골랐을 때(시설 폼·유역 동기화는 배수유역 패널 몫).
* null = 재클릭 해제 — 전 화면(그래프·3D·배수유역도) 선택도 함께 푼다(2026-08-18). */
onPipeSelect: (chainageM: number | null) => void;
}
export function createStructuresSection(callbacks: StructuresCallbacks): StructuresSection {
@@ -187,6 +188,10 @@ export function createStructuresSection(callbacks: StructuresCallbacks): Structu
let editingId: string | null = null;
/** 목록에서 고른 계곡 통과 시설(누가거리 키). 삭제 버튼이 이쪽으로 동작한다. */
let selectedPipeChainage: number | null = null;
/** 임시 배치 중인 A군 시설의 누가거리(2026-08-18) — 측점을 넣는 순간 관 지점에
* 미리 넣어 세부유역·설계유량 미리보기를 얻고, [추가]로만 확정한다. 취소(삭제·
* 리셋·다른 항목 선택·종류 변경)하면 관을 물려 추가 직전 상태로 되돌린다. */
let tempPipeChainage: number | null = null;
let optionInputs: Array<{
key: string;
required: boolean;
@@ -232,6 +237,54 @@ export function createStructuresSection(callbacks: StructuresCallbacks): Structu
designFlow: number | null = null,
): void {
facilityOptions.setFacility(facilityFormKind(currentType()), options, designFlow);
syncOptionLock();
}
/** 위치(측점)가 채워졌는지 — 상세 옵션을 여는 조건(2026-08-18 입력 순서 가이드). */
function hasPosition(): boolean {
const type = currentType();
if (!type) return false;
const step = interval();
if (!type.managed_by && type.placement === "interval") {
return startFields.read(step, false) !== null && endFields.read(step, false) !== null;
}
return anchorFields.read(step, false) !== null;
}
/** 측점 미입력 상태에서는 상세 옵션(레지스트리 옵션 칸·부속 서브폼)을 잠근다 —
* 입력 순서 가이드(2026-08-18 사용자 지시). 자동 배치 시설은 측점을 갖고 폼에
* 올라오므로 항상 열려 있다. */
function syncOptionLock(): void {
const locked = !hasPosition();
optionInputs.forEach((entry) => {
entry.input.disabled = locked;
});
optionRow.classList.toggle("is-locked", locked);
facilityOptions.root.inert = locked;
facilityOptions.root.classList.toggle("is-locked", locked);
}
/** 측점 입력 변경 — A군이면 임시 배치를 만들거나 옮기고(세부유역 미리보기,
* 2026-08-18), 어느 군이든 옵션 잠금을 다시 판정한다. */
function handleStationInput(): void {
const type = currentType();
if (type?.managed_by) {
const anchor = anchorFields.read(interval(), false);
// 기존 관(임시 아님)을 수정 중일 때 기준점 이동은 [수정]으로만 반영한다.
if (anchor !== null && (selectedPipeChainage === null || tempPipeChainage !== null)) {
const attributes: FacilityAttributes = { facility: type.type_id as PipeFacility };
if (tempPipeChainage === null) {
tempPipeChainage = anchor;
callbacks.onPipeAdd(anchor, attributes);
} else if (Math.abs(tempPipeChainage - anchor) > 0.005) {
const from = tempPipeChainage;
tempPipeChainage = anchor;
callbacks.onPipeUpdate(from, anchor, attributes);
}
syncButtons();
}
}
syncOptionLock();
}
/** 타입의 옵션 스키마대로 입력 칸을 다시 그린다 — 상세(detail) 옵션은 B06/B07
@@ -244,6 +297,7 @@ export function createStructuresSection(callbacks: StructuresCallbacks): Structu
const visible = type && !facilityFormKind(type) ? type.options.filter(isB05Option) : [];
if (!visible.length) {
optionRow.hidden = true;
syncOptionLock();
return;
}
optionRow.hidden = false;
@@ -275,6 +329,7 @@ export function createStructuresSection(callbacks: StructuresCallbacks): Structu
isEmpty: () => input.value.trim() === "",
});
});
syncOptionLock();
}
/** 고른 구조물군의 타입을 종류 목록에 채운다 — A군은 계곡 통과 시설(managed_by)도
@@ -294,9 +349,38 @@ export function createStructuresSection(callbacks: StructuresCallbacks): Structu
syncFacilityForm();
}
/** 임시 배치를 물리고 추가 직전 상태로 되돌린다(관 제거 → 세부유역 재분할). */
function cancelTempPipe(): void {
if (tempPipeChainage === null) return;
const chainage = tempPipeChainage;
tempPipeChainage = null;
if (selectedPipeChainage !== null && Math.abs(selectedPipeChainage - chainage) < 0.05) {
selectedPipeChainage = null;
}
callbacks.onPipeRemove(chainage);
}
/** 폼 전체 초기화 — 리셋 버튼과 선택 해제가 같은 상태를 공유한다(2026-08-18
* 사용자 지시: 해제 시 구조물군·종류·측점까지 리셋과 동일하게 비운다). */
function resetForm(): void {
const hadPipe = selectedPipeChainage !== null || tempPipeChainage !== null;
cancelTempPipe();
groupSelect.value = "";
syncTypeOptions();
loadForm(null);
// 시설이 골라져 있었으면 전 화면(그래프·3D·배수유역도) 선택도 함께 푼다.
if (hadPipe) callbacks.onPipeSelect(null);
}
function syncButtons(): void {
primary.textContent = editingId || selectedPipeChainage !== null ? "수정" : "추가";
removeButton.disabled = editingId === null && selectedPipeChainage === null;
primary.textContent =
tempPipeChainage !== null
? "추가"
: editingId || selectedPipeChainage !== null
? "수정"
: "추가";
removeButton.disabled =
editingId === null && selectedPipeChainage === null && tempPipeChainage === null;
}
function renderList(): void {
@@ -308,16 +392,27 @@ export function createStructuresSection(callbacks: StructuresCallbacks): Structu
intervalM: interval(),
editingId,
selectedPipeChainage,
onSelectStructure: (structure) =>
loadForm(structure.structure_id === editingId ? null : structure),
// 재선택 = 해제(2026-08-18 복구) — 해제는 리셋과 같은 전체 초기화다.
onSelectStructure: (structure) => {
if (structure.structure_id !== null && structure.structure_id === editingId) resetForm();
else loadForm(structure);
},
onSelectPipe: (pipe) => {
loadPipeForm(pipe);
callbacks.onPipeSelect(pipe.chainage_m);
const isSame =
selectedPipeChainage !== null && Math.abs(selectedPipeChainage - pipe.chainage_m) < 0.05;
if (isSame) {
resetForm();
} else {
loadPipeForm(pipe);
callbacks.onPipeSelect(pipe.chainage_m);
}
},
});
}
function loadForm(target: StructureInstance | null): void {
// 다른 항목으로 넘어가면 임시 배치(A군 미리보기)는 취소된다.
cancelTempPipe();
editingId = target?.structure_id ?? null;
selectedPipeChainage = null;
const step = interval();
@@ -351,6 +446,11 @@ export function createStructuresSection(callbacks: StructuresCallbacks): Structu
/** 계곡 통과 시설을 폼에 올린다 — 일반 구조물과 같은 흐름(종류·측점·옵션·수정).
* 정본이 관 지점이라 editingId 대신 선택 누가거리로 추적한다. */
function loadPipeForm(pipe: PipeFacilityItem): void {
// 임시 배치 중인 그 관이 재계산을 거쳐 되돌아온 경우가 아니면, 다른 항목으로
// 넘어가는 것이므로 임시 배치를 취소한다.
const isTemp = tempPipeChainage !== null && Math.abs(pipe.chainage_m - tempPipeChainage) < 0.05;
if (isTemp) tempPipeChainage = pipe.chainage_m;
else cancelTempPipe();
const hadStructure = editingId !== null;
editingId = null;
selectedPipeChainage = pipe.chainage_m;
@@ -407,7 +507,12 @@ export function createStructuresSection(callbacks: StructuresCallbacks): Structu
const attributes: FacilityAttributes = { facility: type.type_id as PipeFacility };
const options = facilityOptions.readOptions();
if (Object.keys(options).length) attributes.options = options;
if (selectedPipeChainage !== null) {
if (tempPipeChainage !== null) {
// 임시 배치 확정([추가]) — 관은 이미 미리보기로 들어가 있으니 옵션·위치만 반영.
const from = tempPipeChainage;
tempPipeChainage = null;
callbacks.onPipeUpdate(from, anchor, attributes);
} else if (selectedPipeChainage !== null) {
callbacks.onPipeUpdate(selectedPipeChainage, anchor, attributes);
} else {
callbacks.onPipeAdd(anchor, attributes);
@@ -474,6 +579,7 @@ export function createStructuresSection(callbacks: StructuresCallbacks): Structu
}
groupSelect.addEventListener("change", () => {
cancelTempPipe(); // 종류가 달라진다 — 임시 배치는 취소(2026-08-18).
syncTypeOptions();
// 종류가 바뀌면 기존 항목 수정이 아니라 새 항목 추가로 넘어간다(옵션 스키마가 달라진다).
if (editingId) {
@@ -484,6 +590,7 @@ export function createStructuresSection(callbacks: StructuresCallbacks): Structu
}
});
typeSelect.addEventListener("change", () => {
cancelTempPipe(); // 종류가 달라진다 — 임시 배치는 취소(2026-08-18).
syncPlacementFields();
renderOptionFields();
// 배수관·BOX암거·세월교·독립 기슭막이는 부속 옵션 서브폼이 따라 열려야 한다 —
@@ -499,6 +606,11 @@ export function createStructuresSection(callbacks: StructuresCallbacks): Structu
});
primary.addEventListener("click", commit);
removeButton.addEventListener("click", () => {
// 임시 배치 중이면 취소와 같다 — 관을 물리고 추가 직전 상태로(2026-08-18).
if (tempPipeChainage !== null) {
resetForm();
return;
}
// 계곡 통과 시설이 골라져 있으면 관 지점 정본에서 지운다(배수유역 패널 경유).
if (selectedPipeChainage !== null) {
callbacks.onPipeRemove(selectedPipeChainage);
@@ -513,12 +625,15 @@ export function createStructuresSection(callbacks: StructuresCallbacks): Structu
loadForm(null);
emit();
});
resetButton.addEventListener("click", () => {
// 리셋은 폼만 아니라 구조물군·종류도 빈칸으로 되돌린다(2026-08-17 사용자 지시).
groupSelect.value = "";
syncTypeOptions();
loadForm(null);
});
// 리셋은 폼만 아니라 구조물군·종류도 빈칸으로 되돌린다(2026-08-17 사용자 지시).
// 임시 배치 취소도 겸한다(2026-08-18).
resetButton.addEventListener("click", resetForm);
// 측점 입력 감시 — 옵션 잠금 해제 판정 + A군 임시 배치 생성·이동(2026-08-18).
[anchorFields, startFields, endFields].forEach((fields) =>
[fields.station, fields.remainder].forEach((input) =>
input.addEventListener("change", handleStationInput),
),
);
body.insertBefore(field("메모", memoField), actions);
syncButtons();
@@ -548,6 +663,14 @@ export function createStructuresSection(callbacks: StructuresCallbacks): Structu
getStructures: () => [...structures],
setPipeFacilities(pipes) {
pipeFacilities = pipes.map((pipe) => ({ ...pipe }));
if (
tempPipeChainage !== null &&
!pipeFacilities.some((pipe) => Math.abs(pipe.chainage_m - tempPipeChainage!) < 0.05)
) {
// 임시 배치 관이 재계산에서 사라졌다 — 임시 상태만 정리(관은 이미 없다).
tempPipeChainage = null;
syncButtons();
}
if (
selectedPipeChainage !== null &&
!pipeFacilities.some((pipe) => Math.abs(pipe.chainage_m - selectedPipeChainage!) < 0.05)
@@ -561,6 +684,11 @@ export function createStructuresSection(callbacks: StructuresCallbacks): Structu
},
selectPipeByChainage(chainageM) {
if (chainageM === null) {
// 임시 배치 중 외부 해제(다른 곳 클릭) = 취소 — 추가 직전 상태로(2026-08-18).
if (tempPipeChainage !== null) {
resetForm();
return;
}
if (selectedPipeChainage === null) return;
selectedPipeChainage = null;
facilityOptions.setFacility(null);
@@ -584,58 +712,34 @@ export function createStructuresSection(callbacks: StructuresCallbacks): Structu
addAt(chainageM, typeId) {
const type = typeMap().get(typeId);
if (!type) return;
// 계곡 통과 시설은 관 지점 정본으로 바로 보낸다(옵션은 추가 후 폼에서).
// BOX암거만 예외로 레지스트리 기본값(본체 2.0×2.0·날개벽 있음 1m/2m/45°)을
// 함께 싣는다 — 폼을 열지 않고 넣어도 하류가 제원을 받는다(2026-08-17 사용자
// 확정). 배관·물넘이·세월교는 부속을 설계자가 고를 몫이라 빈 채로 둔다.
if (type.managed_by) {
const attributes: FacilityAttributes = { facility: typeId as PipeFacility };
if (typeId === "box_culvert") attributes.options = defaultOptions(type);
callbacks.onPipeAdd(chainageM, attributes);
return;
}
// 우클릭 추가 = 즉시 추가 폐지(2026-08-18 사용자 지시 — 옵션 확대로 바로 넣을
// 수 없는 타입이 생겼다). 폼에 종류·측점을 자동으로 실어 옵션을 활성화하고,
// [추가]로 확정한다. A군은 측점이 실리는 순간 임시 배치(세부유역 미리보기)까지
// 자동으로 진행된다.
cancelTempPipe();
groupSelect.value = type.group;
syncTypeOptions(typeId);
typeSelect.value = typeId;
editingId = null;
selectedPipeChainage = null;
const placement = placementOf(typeId);
const step = interval();
// B05 단계 필수 옵션이 있는 타입만 폼을 연다 — 상세(detail) 필수는 B06/B07
// 몫이라 바로 추가해도 서버가 받는다(2026-08-17 phase 분리).
if (type.options.some((option) => option.required && isB05Option(option))) {
groupSelect.value = type.group;
syncTypeOptions(typeId);
typeSelect.value = typeId;
editingId = null;
anchorFields.write(chainageM, step);
startFields.write(placement === "interval" ? chainageM : null, step);
endFields.write(
placement === "interval" ? chainageM + DEFAULT_INTERVAL_LENGTH_M : null,
step,
);
memoField.value = "";
syncPlacementFields();
renderOptionFields();
syncButtons();
renderList();
root.scrollIntoView({ block: "nearest" });
const firstRequired = optionInputs.find((entry) => entry.required);
(firstRequired?.input ?? anchorFields.station).focus();
return;
}
structures.push({
structure_id: null,
type_id: typeId,
placement,
chainage_m: chainageM,
start_m: placement === "interval" ? chainageM : null,
end_m: placement === "interval" ? chainageM + DEFAULT_INTERVAL_LENGTH_M : null,
options: defaultOptions(type),
memo: "",
placement_source: "manual",
status: "draft",
revision: 0,
geometry: null,
});
emit();
const isInterval = !type.managed_by && placement === "interval";
anchorFields.write(chainageM, step);
startFields.write(isInterval ? chainageM : null, step);
endFields.write(isInterval ? chainageM + DEFAULT_INTERVAL_LENGTH_M : null, step);
memoField.value = "";
syncPlacementFields();
renderOptionFields(type.managed_by ? undefined : defaultOptions(type));
// BOX암거는 레지스트리 기본값(본체 2.0×2.0·날개벽 있음 1m/2m/45°)을 실어 폼을
// 연다 — [추가]만 눌러도 하류가 제원을 받는다(2026-08-17 사용자 확정 유지).
syncFacilityForm(typeId === "box_culvert" ? defaultOptions(type) : {});
if (type.managed_by) handleStationInput();
syncButtons();
renderList();
root.scrollIntoView({ block: "nearest" });
const firstRequired = optionInputs.find((entry) => entry.required);
(firstRequired?.input ?? anchorFields.station).focus();
},
moveById(structureId, toChainageM) {
const index = structures.findIndex((entry) => entry.structure_id === structureId);
+12
View File
@@ -233,6 +233,18 @@
}
/* 제목 행 클릭 접기/펼치기는 공용 collapsible(ui_template_theme.css)에서 처리한다. */
/* 사용여부 미결정 컨테이너(경로 계산 설정) — 숨기지 않고 "조작 안 되는 항목"임이
보이게 글자·배경을 가라앉힌다(2026-08-18 사용자 지시). 입력 차단은 본문 inert가
맡고, 여기서는 시각만 담당한다. 제목(접기)은 살아 있으므로 흐림에서 제외하지
않는다 — 컨테이너 전체가 한 덩어리로 비활성으로 읽혀야 한다. */
.b05-route__panel-section.is-disabled {
background: color-mix(in srgb, var(--color-surface-raised) 60%, var(--color-canvas));
}
.b05-route__panel-section.is-disabled > * {
opacity: 0.45;
}
.b05-route__panel-section h3 {
margin: 0;
color: var(--color-text);
@@ -165,6 +165,13 @@
min-width: 0;
}
/* 측점 미입력 상태의 상세 옵션 잠금 — 입력 순서 가이드(2026-08-18 사용자 지시).
조작 차단은 disabled·inert가 맡고 여기서는 흐림만 그린다. */
.b05-structure__grid.is-locked,
.b05-drainage__facility.is-locked {
opacity: 0.5;
}
/* 측점 그룹과 그 아래 옵션 나열을 가르는 선(2026-08-17 사용자 지시 2). */
.b05-structure__divider {
width: 100%;